@ai-matrx/chat 0.7.20 → 0.7.21

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.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,38 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.7.21
4
+ ### Unreleased
5
+
6
+ ### /chat/new: the live residuals of the startup-read work
7
+
8
+ - **A minted id is a draft before its record exists.** `generateConversationId` remembers the ids this tab
9
+ minted (`wasMintedInThisTab`); `selectIsDraftConversation` answers true for one Redux does not hold yet.
10
+ The composer mounts — and its children's effects run — before the launch thunk creates the record, so
11
+ plan/tasks/todos, document + scratchpad links and the materialized probe fired for every new draft.
12
+ - **One agent's name is one row.** `useAgentName` / `useAgentDescription` / `useAgentIdentity` ask
13
+ `fetchAgentRunTier(agentId)` when the record has not read the field; the whole catalogue is read only
14
+ when that fails. `fetchAgentRunTier` joins a run-tier read already in flight instead of forcing a second.
15
+ - **One mandate verdict per question.** The resolution GET is shared across the optional and required
16
+ lanes and with `resolveMandateHolder` (cached like resolutions; dropped on invalidation and org switch).
17
+ New `useMandateHolderSet(keys, { enabled })` — the verdict only; the composer's quick-action row uses it
18
+ instead of `useMandateSet`, so its jobs no longer read each definition + treatment row.
19
+ - **The greeting paints its final word.** `ComposerGreeting` ships an inline script that writes the local
20
+ time-of-day word before first paint; hydration renders the same word (no Largest Contentful Paint swap).
21
+ - The composer rail loads the saved context rules once the page is idle, not at mount.
22
+ - **Consumer action:** none.
23
+
3
24
  ## 0.7.20
4
25
 
5
26
  Automatic changed-only republish (docs/metadata drift since the last tag — see
6
27
  `git diff npm/chat/v0.7.19..npm/chat/v0.7.20 -- apps/shared/chat`).
7
28
  No source changes intended and no consumer action required.
8
29
 
30
+ ## 0.7.19
31
+
32
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
33
+ `git diff npm/chat/v0.7.18..npm/chat/v0.7.19 -- apps/shared/chat`).
34
+ No source changes intended and no consumer action required.
35
+
9
36
  ## 0.7.18
10
37
 
11
38
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -13,6 +13,7 @@ import {
13
13
  import { useAppDispatch, useAppSelector } from "../../../../store/hooks.js";
14
14
  import { ensureSurfaceFeatureLoaded } from "../../../../surfaces/redux/userStateSlice.js";
15
15
  import { CONTEXT_RULES_FEATURE } from "@ai-matrx/agents/context";
16
+ import { whenPageIdle } from "@ai-matrx/kit/idle-scheduler";
16
17
  import { cn } from "@ai-matrx/design-system";
17
18
  import { useIsMobile } from "@ai-matrx/kit/media-query";
18
19
  import {
@@ -124,7 +125,11 @@ function ConversationContextRailBody({
124
125
  useConversationFollowsPage(conversationId);
125
126
  const contextChipShown = useConversationContextChipShown(conversationId) && withValueGroupChip;
126
127
  useEffect(() => {
127
- void dispatch(ensureSurfaceFeatureLoaded(CONTEXT_RULES_FEATURE));
128
+ const idle = new AbortController();
129
+ void whenPageIdle(idle.signal).then((ready) => {
130
+ if (ready) void dispatch(ensureSurfaceFeatureLoaded(CONTEXT_RULES_FEATURE));
131
+ });
132
+ return () => idle.abort();
128
133
  }, [dispatch]);
129
134
  const workingDocEnabled = useAppSelector(
130
135
  selectWorkingDocEnabled(conversationId, "working")
@@ -6,7 +6,7 @@
6
6
  * The list is DATA: the `agents.chat_composer.quick_actions` knob (system
7
7
  * default, overridable per organization and per person). Each entry names a
8
8
  * label and the agent JOB (mandate) it opens; the job resolves through the
9
- * mandate ladder (`useMandateSet`), exactly like today's /chat/new chips. An
9
+ * mandate ladder (`useMandateHolderSet`: the verdict only, never a launch read). An
10
10
  * entry whose job cannot resolve renders disabled with the reason — never a
11
11
  * silent fallback.
12
12
  */
@@ -10,7 +10,7 @@ import { ChevronRight } from "lucide-react";
10
10
  import { isMandateKey } from "@ai-matrx/agents/mandates";
11
11
  import { useAppSelector } from "../../../../../store/hooks.js";
12
12
  import { selectActiveUserName } from "../../../../../host/identity.js";
13
- import { useMandateSet } from "../../../../../mandates/useMandateSet.js";
13
+ import { useMandateHolderSet } from "../../../../../mandates/useMandateHolderSet.js";
14
14
  import { resolveMandate } from "../../../../../mandates/service.js";
15
15
  import { toast } from "../../../../../host/notify.js";
16
16
  import { useIdleReady } from "@ai-matrx/kit/idle-scheduler";
@@ -27,6 +27,7 @@ function timeOfDayGreeting(hour) {
27
27
  }
28
28
  const noSubscription = () => () => {
29
29
  };
30
+ const GREETING_SCRIPT = `(function(s){var h=s&&s.previousElementSibling;var e=h&&h.querySelector('[data-matrx-greeting]');if(e)e.textContent=(${timeOfDayGreeting.toString()})(new Date().getHours())})(document.currentScript)`;
30
31
  function ComposerGreeting({ className }) {
31
32
  const userName = useAppSelector(selectActiveUserName);
32
33
  const firstName = (userName ?? "").trim().split(/\s+/)[0] || "";
@@ -44,9 +45,10 @@ function ComposerGreeting({ className }) {
44
45
  }
45
46
  ),
46
47
  /* @__PURE__ */ jsxs("h1", { className: "font-serif text-[clamp(2rem,1.6rem+1.8vw,2.875rem)] font-normal tracking-tight text-foreground", children: [
47
- greeting ?? "Hello",
48
+ /* @__PURE__ */ jsx("span", { "data-matrx-greeting": "", suppressHydrationWarning: true, children: greeting ?? "Hello" }),
48
49
  firstName ? `, ${firstName}` : ""
49
- ] })
50
+ ] }),
51
+ /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: GREETING_SCRIPT } })
50
52
  ] });
51
53
  }
52
54
  function parseQuickActions(value) {
@@ -77,12 +79,12 @@ function ComposerQuickActions({
77
79
  const actions = parseQuickActions(knob);
78
80
  const quickActionKeys = actions.map((a) => a.mandateKey);
79
81
  const idle = useIdleReady();
80
- const mandates = useMandateSet(quickActionKeys, {
82
+ const mandates = useMandateHolderSet(quickActionKeys, {
81
83
  enabled: Boolean(organizationId) && idle
82
84
  });
83
85
  const [launching, setLaunching] = useState(null);
84
86
  const launch = (action) => {
85
- const known = mandates[action.mandateKey]?.mandate?.agentId;
87
+ const known = mandates[action.mandateKey]?.agentId;
86
88
  if (known) {
87
89
  onLaunchAgent(known);
88
90
  return;
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { getChatHost } from "../../host/configure.js";
3
3
  const getAgentCatalog = () => getChatHost().catalog();
4
- import { useAppSelector } from "../../store/hooks.js";
5
- import { useEffect, useMemo, useSyncExternalStore } from "react";
4
+ import { useAppDispatch, useAppSelector } from "../../store/hooks.js";
5
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
6
+ import { fetchAgentRunTier } from "../redux/agent-definition/thunks.js";
6
7
  import { hasField } from "@ai-matrx/agents/field-flags";
7
8
  import { selectAgentById } from "../redux/agent-definition/selectors.js";
8
9
  import { makeEmptyRecord } from "../redux/agent-definition/slice.js";
@@ -114,21 +115,40 @@ function useAgentView(agentId) {
114
115
  function readAgentView(state, agentId) {
115
116
  return agentViewOf(recordOf(state, agentId), catalogRow(agentId));
116
117
  }
118
+ function useIdentityRow(agentId, record, fields) {
119
+ const dispatch = useAppDispatch();
120
+ const needed = Boolean(agentId) && catalogNeededFor(record, fields);
121
+ const [runTierFailedFor, setRunTierFailedFor] = useState(null);
122
+ useEffect(() => {
123
+ if (!agentId || !needed) return void 0;
124
+ let cancelled = false;
125
+ dispatch(fetchAgentRunTier(agentId)).unwrap().catch(() => {
126
+ if (!cancelled) setRunTierFailedFor(agentId);
127
+ });
128
+ return () => {
129
+ cancelled = true;
130
+ };
131
+ }, [dispatch, agentId, needed]);
132
+ return useAgentSummaryRow(agentId, { load: needed && runTierFailedFor === agentId });
133
+ }
117
134
  function useAgentName(agentId) {
118
135
  const record = useAppSelector((s) => recordOf(s, agentId));
119
- const row = useAgentSummaryRow(agentId, { load: catalogNeededFor(record, ["name"]) });
136
+ const row = useIdentityRow(agentId, record, NAME);
120
137
  return nameOf(record, row);
121
138
  }
122
139
  function useAgentDescription(agentId) {
123
140
  const record = useAppSelector((s) => recordOf(s, agentId));
124
- const row = useAgentSummaryRow(agentId, { load: catalogNeededFor(record, ["description"]) });
141
+ const row = useIdentityRow(agentId, record, DESCRIPTION);
125
142
  return descriptionOf(record, row);
126
143
  }
127
144
  function useAgentIdentity(agentId) {
128
145
  const record = useAppSelector((s) => recordOf(s, agentId));
129
- const summary = useAgentSummaryRow(agentId, { load: catalogNeededFor(record, ["name", "description"]) });
146
+ const summary = useIdentityRow(agentId, record, NAME_AND_DESCRIPTION);
130
147
  return { name: nameOf(record, summary), description: descriptionOf(record, summary), summary };
131
148
  }
149
+ const NAME = ["name"];
150
+ const DESCRIPTION = ["description"];
151
+ const NAME_AND_DESCRIPTION = ["name", "description"];
132
152
  function readAgentName(state, agentId) {
133
153
  return nameOf(recordOf(state, agentId), catalogRow(agentId));
134
154
  }
@@ -140,7 +140,9 @@ const fetchAgentRunTier = createAsyncThunk(
140
140
  if (await isSignedOutVisitor()) throw new NotAuthenticatedError();
141
141
  dispatch(setAgentLoading({ id: agentId, loading: true }));
142
142
  try {
143
- await getRunTierStore().loadRunTier(agentId, { force: true });
143
+ const store = getRunTierStore();
144
+ const inFlight = store.getState().runTier[agentId]?.status === "loading";
145
+ await store.loadRunTier(agentId, { force: !inFlight });
144
146
  } catch (e) {
145
147
  dispatch(setAgentLoading({ id: agentId, loading: false }));
146
148
  const failure = runTierFailure(agentId, e);
@@ -270,10 +270,15 @@ export declare const selectIsCacheOnly: (conversationId: string) => (state: Chat
270
270
  * Is this a DRAFT — a conversation minted in this tab that has no server row
271
271
  * yet (`cacheOnly`, until the first send's confirmation or a DB hydrate flips
272
272
  * it)? Reads keyed by a draft's id can only answer "nothing": gate them on
273
- * this and they run the moment the first send makes it real. A conversation
274
- * Redux does not hold is NOT a draft (it was opened from elsewhere, so it has a
275
- * row) — unlike `selectIsCacheOnly`, which answers true for an unknown id
276
- * because it gates WRITES.
273
+ * this and they run the moment the first send makes it real.
274
+ *
275
+ * A conversation Redux holds answers from its record. One Redux does NOT hold
276
+ * is a draft only if this tab minted its id (`wasMintedInThisTab`) — the
277
+ * window between the launcher minting the id and the launch thunk creating the
278
+ * record, in which /chat/new's composer has already mounted and run its
279
+ * effects. Any other unknown id was opened from elsewhere, so it has a row —
280
+ * unlike `selectIsCacheOnly`, which answers true for every unknown id because
281
+ * it gates WRITES.
277
282
  */
278
283
  export declare const selectIsDraftConversation: (conversationId: string | null | undefined) => (state: ChatRootState) => boolean;
279
284
  export declare const selectConversationTitle: (conversationId: string) => (state: ChatRootState) => string | null;
@@ -1,4 +1,5 @@
1
1
  import { createSelector } from "@reduxjs/toolkit";
2
+ import { wasMintedInThisTab } from "../utils/ids.js";
2
3
  const EMPTY_INSTANCES = [];
3
4
  const EMPTY_CONVERSATION_IDS_BY_AGENT = {};
4
5
  const selectInstance = (conversationId) => (state) => state.conversations.byConversationId[conversationId];
@@ -44,8 +45,12 @@ const selectRunningInstances = createSelector(
44
45
  );
45
46
  const selectAgentIdFromInstance = (conversationId) => (state) => state.conversations.byConversationId[conversationId]?.agentId;
46
47
  const selectIsCacheOnly = (conversationId) => (state) => state.conversations.byConversationId[conversationId]?.cacheOnly ?? true;
47
- const selectIsDraftConversation = (conversationId) => (state) => !!conversationId && // A store without the conversations slice holds no drafts.
48
- state.conversations?.byConversationId[conversationId]?.cacheOnly === true;
48
+ const selectIsDraftConversation = (conversationId) => (state) => {
49
+ if (!conversationId) return false;
50
+ const record = state.conversations?.byConversationId[conversationId];
51
+ if (record) return record.cacheOnly === true;
52
+ return wasMintedInThisTab(conversationId);
53
+ };
49
54
  const selectConversationTitle = (conversationId) => (state) => state.conversations.byConversationId[conversationId]?.title ?? null;
50
55
  const selectConversationDescription = (conversationId) => (state) => state.conversations.byConversationId[conversationId]?.description ?? null;
51
56
  const selectConversationKeywords = (conversationId) => (state) => state.conversations.byConversationId[conversationId]?.keywords ?? null;
@@ -6,6 +6,8 @@
6
6
  * as-is and used as the authoritative conversation key.
7
7
  */
8
8
  export declare const generateConversationId: () => string;
9
+ /** True for an id `generateConversationId` minted in this tab. */
10
+ export declare const wasMintedInThisTab: (conversationId: string) => boolean;
9
11
  /** @deprecated Use generateConversationId() */
10
12
  export declare const generateInstanceId: typeof generateConversationId;
11
13
  export declare const generateResourceId: () => string;
@@ -1,4 +1,10 @@
1
- const generateConversationId = () => crypto.randomUUID();
1
+ const mintedInThisTab = /* @__PURE__ */ new Set();
2
+ const generateConversationId = () => {
3
+ const id = crypto.randomUUID();
4
+ mintedInThisTab.add(id);
5
+ return id;
6
+ };
7
+ const wasMintedInThisTab = (conversationId) => mintedInThisTab.has(conversationId);
2
8
  const generateInstanceId = generateConversationId;
3
9
  const generateResourceId = () => `res_${crypto.randomUUID()}`;
4
10
  const generateRequestId = () => `req_${crypto.randomUUID()}`;
@@ -8,5 +14,6 @@ export {
8
14
  generateInstanceId,
9
15
  generateRequestId,
10
16
  generateResourceId,
11
- generateScopeId
17
+ generateScopeId,
18
+ wasMintedInThisTab
12
19
  };
@@ -96,7 +96,12 @@ function dropMandateCacheForOrgSwitch() {
96
96
  cache.delete(key);
97
97
  dropped = true;
98
98
  }
99
+ for (const key of verdictCache.keys()) {
100
+ if (current !== null && key.split(":")[1] === current) continue;
101
+ verdictCache.delete(key);
102
+ }
99
103
  resolutionsInFlight.clear();
104
+ verdictsInFlight.clear();
100
105
  cacheGeneration += 1;
101
106
  if (!dropped) return;
102
107
  for (const listener of invalidationListeners) listener(void 0);
@@ -111,13 +116,18 @@ function invalidateMandateCache(mandateKey) {
111
116
  invalidateMandateCatalogueCache();
112
117
  cacheGeneration += 1;
113
118
  resolutionsInFlight.clear();
119
+ verdictsInFlight.clear();
114
120
  if (mandateKey) {
115
121
  for (const key of cache.keys()) {
116
122
  if (key.endsWith(`:${mandateKey}`)) cache.delete(key);
117
123
  }
124
+ for (const key of verdictCache.keys()) {
125
+ if (key.endsWith(`:${mandateKey}`)) verdictCache.delete(key);
126
+ }
118
127
  pinCache.delete(mandateKey);
119
128
  } else {
120
129
  cache.clear();
130
+ verdictCache.clear();
121
131
  pinCache.clear();
122
132
  }
123
133
  for (const listener of invalidationListeners) listener(mandateKey);
@@ -172,7 +182,7 @@ async function selectedOrganizationFor(mandateKey) {
172
182
  }
173
183
  async function fetchResolutionVerdict(mandateKey, options) {
174
184
  const question = await resolutionQuestion(mandateKey, options);
175
- const verdict = await askResolutionDoor(mandateKey, options, question.organizationId);
185
+ const verdict = await askVerdict(mandateKey, options, question);
176
186
  if (verdict === null) return null;
177
187
  return { kind: "verdict", verdict, ...question };
178
188
  }
@@ -189,7 +199,7 @@ async function resolutionQuestion(mandateKey, options) {
189
199
  const organizationId = options.organizationId ?? await selectedOrganizationFor(mandateKey);
190
200
  return { organizationId, cacheKey: mandateCacheKey(userId, organizationId, mandateKey), guest: false };
191
201
  }
192
- async function askResolutionDoor(mandateKey, options, organizationId) {
202
+ async function askResolutionDoor(mandateKey, captureErrors, organizationId) {
193
203
  let verdict;
194
204
  try {
195
205
  const { data } = await apiGet(
@@ -210,43 +220,78 @@ async function askResolutionDoor(mandateKey, options, organizationId) {
210
220
  // answering 404 is the documented result, not a system error, so it must
211
221
  // not enter the global Error Inspector. Anything that is not a 404 is
212
222
  // re-thrown below and captured by the consumer that asked.
213
- captureErrors: !options.optional
223
+ captureErrors
214
224
  }
215
225
  );
216
226
  verdict = data;
217
227
  } catch (error) {
218
- if (error instanceof BackendApiError && error.status === 404) {
219
- if (options.optional) return null;
220
- throw recordUnavailable({
221
- entity: "mandate",
222
- reason: "unknown",
223
- recordId: mandateKey,
224
- relation: "mandate.definition"
225
- });
226
- }
228
+ if (error instanceof BackendApiError && error.status === 404) return null;
227
229
  throw error;
228
230
  }
229
231
  return verdict;
230
232
  }
233
+ function mandateMissing(mandateKey) {
234
+ return recordUnavailable({
235
+ entity: "mandate",
236
+ reason: "unknown",
237
+ recordId: mandateKey,
238
+ relation: "mandate.definition"
239
+ });
240
+ }
241
+ async function askVerdict(mandateKey, options, question) {
242
+ const answer = await sharedVerdict(mandateKey, options, question);
243
+ if (answer === MISSING) {
244
+ if (options.optional) return null;
245
+ throw mandateMissing(mandateKey);
246
+ }
247
+ return answer;
248
+ }
249
+ const MISSING = "missing";
250
+ const verdictCache = /* @__PURE__ */ new Map();
251
+ const verdictsInFlight = /* @__PURE__ */ new Map();
252
+ function sharedVerdict(mandateKey, options, question) {
253
+ const key = question.cacheKey;
254
+ const cached = verdictCache.get(key);
255
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return Promise.resolve(cached.value);
256
+ const pending = verdictsInFlight.get(key);
257
+ if (pending) return pending;
258
+ const generation = cacheGeneration;
259
+ const run = askResolutionDoor(mandateKey, !options.optional, question.organizationId).then((verdict) => {
260
+ const value = verdict ?? MISSING;
261
+ if (generation === cacheGeneration) verdictCache.set(key, { at: Date.now(), value });
262
+ return value;
263
+ }).finally(() => {
264
+ if (verdictsInFlight.get(key) === run) verdictsInFlight.delete(key);
265
+ });
266
+ verdictsInFlight.set(key, run);
267
+ return run;
268
+ }
231
269
  async function resolveMandate(mandateKey, options = {}) {
232
270
  const question = await resolutionQuestion(mandateKey, options);
233
271
  const cached = cache.get(question.cacheKey);
234
272
  if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
235
- const flightKey = `${question.cacheKey}|${options.optional ? "optional" : "required"}`;
236
- const pending = resolutionsInFlight.get(flightKey);
237
- if (pending) return pending;
238
- const run = resolveFromDoor(mandateKey, options, question).finally(() => {
239
- if (resolutionsInFlight.get(flightKey) === run) resolutionsInFlight.delete(flightKey);
240
- });
241
- resolutionsInFlight.set(flightKey, run);
242
- return run;
273
+ const flightKey = question.cacheKey;
274
+ let run = resolutionsInFlight.get(flightKey);
275
+ if (!run) {
276
+ const started = resolveFromDoor(mandateKey, options, question).finally(() => {
277
+ if (resolutionsInFlight.get(flightKey) === started) resolutionsInFlight.delete(flightKey);
278
+ });
279
+ resolutionsInFlight.set(flightKey, started);
280
+ run = started;
281
+ }
282
+ const answer = await run;
283
+ if (answer === MISSING) {
284
+ if (options.optional) return null;
285
+ throw mandateMissing(mandateKey);
286
+ }
287
+ return answer;
243
288
  }
244
289
  const resolutionsInFlight = /* @__PURE__ */ new Map();
245
290
  async function resolveFromDoor(mandateKey, options, question) {
246
291
  const supabase = createClient();
247
292
  const generation = cacheGeneration;
248
- const verdict = await askResolutionDoor(mandateKey, options, question.organizationId);
249
- if (verdict === null) return null;
293
+ const verdict = await sharedVerdict(mandateKey, options, question);
294
+ if (verdict === MISSING) return MISSING;
250
295
  const { organizationId, cacheKey, guest } = question;
251
296
  const holder = assertRunnableVerdict(mandateKey, verdict);
252
297
  const provenance = verdict.provenance;
@@ -283,15 +328,7 @@ async function resolveFromDoor(mandateKey, options, question) {
283
328
  }
284
329
  const { data: mandate, error } = await mandateDefinitions(supabase).select("*").eq("mandate_key", mandateKey).is("deleted_at", null).maybeSingle();
285
330
  if (error) throw error;
286
- if (!mandate) {
287
- if (options.optional) return null;
288
- throw recordUnavailable({
289
- entity: "mandate",
290
- reason: "unknown",
291
- recordId: mandateKey,
292
- relation: "mandate.definition"
293
- });
294
- }
331
+ if (!mandate) return MISSING;
295
332
  let presentation = null;
296
333
  {
297
334
  const { data: treatment, error: treatmentError } = await mandateTreatments(
@@ -0,0 +1,9 @@
1
+ import type { AnyMandateKey } from "@ai-matrx/agents/mandates";
2
+ export interface MandateHolderState {
3
+ agentId: string | null;
4
+ loading: boolean;
5
+ error: string | null;
6
+ }
7
+ export declare function useMandateHolderSet(keys: readonly AnyMandateKey[], options?: {
8
+ enabled?: boolean;
9
+ }): Readonly<Record<string, MandateHolderState>>;
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { extractErrorMessage } from "@ai-matrx/data/net";
4
+ import { onMandateCacheInvalidated, resolveMandateHolder } from "./service.js";
5
+ import { useAppSelector } from "../store/hooks.js";
6
+ import { selectOrganizationId } from "../host/org.js";
7
+ const SEPARATOR = "\0";
8
+ function useMandateHolderSet(keys, options = {}) {
9
+ const enabled = options.enabled ?? true;
10
+ const organizationId = useAppSelector(selectOrganizationId) ?? null;
11
+ const keyList = enabled ? keys.join(SEPARATOR) : "";
12
+ const question = keyList ? `${keyList}|${organizationId ?? ""}` : "";
13
+ const [state, setState] = useState(
14
+ () => ({ question: "", epoch: 0, set: {} })
15
+ );
16
+ if (state.question !== question) setState({ question, epoch: 0, set: {} });
17
+ useEffect(() => {
18
+ if (!keyList) return void 0;
19
+ const listed = keyList.split(SEPARATOR);
20
+ return onMandateCacheInvalidated((key) => {
21
+ if (key === void 0 || listed.includes(key)) setState((prev) => ({ ...prev, epoch: prev.epoch + 1 }));
22
+ });
23
+ }, [keyList]);
24
+ const epoch = state.epoch;
25
+ useEffect(() => {
26
+ if (!keyList) return void 0;
27
+ const listed = keyList.split(SEPARATOR);
28
+ let cancelled = false;
29
+ void Promise.allSettled(listed.map((key) => resolveMandateHolder(key))).then((results) => {
30
+ if (cancelled) return;
31
+ const next = {};
32
+ results.forEach((result, i) => {
33
+ const key = listed[i];
34
+ if (result.status === "fulfilled" && result.value?.holderType === "agent") {
35
+ next[key] = { agentId: result.value.holderId, loading: false, error: null };
36
+ return;
37
+ }
38
+ const message = result.status === "rejected" ? extractErrorMessage(result.reason) : `${key} is held by a workflow, not an agent`;
39
+ console.error(`[mandates] ${key} has no agent to open:`, message);
40
+ next[key] = { agentId: null, loading: false, error: message };
41
+ });
42
+ setState((prev) => prev.question === question ? { ...prev, set: next } : prev);
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }, [keyList, question, epoch]);
48
+ return state.set;
49
+ }
50
+ export {
51
+ useMandateHolderSet
52
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.7.20",
3
+ "version": "0.7.21",
4
4
  "description": "THE one AI Matrx person-to-agent chat system: the full chat (running agents, conversations, tool calls, streaming, context, surfaces, voice) as explicit per-module subpaths @ai-matrx/chat/<domain>/<module> (no wildcards: scripts/public-modules.json) over a host contract (/host, /host/react) whose only required value is a db. One chat, one renderer: the old kernel entries (., /external-store, /matrx, /react, /tools, styles.css, tokens.css) are retired.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4681,25 +4681,25 @@
4681
4681
  "server-only": "^0.0.1",
4682
4682
  "uuid": "^14.0.2",
4683
4683
  "zod": "^4.6.5",
4684
- "@ai-matrx/alchemy": "latest",
4685
- "@ai-matrx/associations": "latest",
4686
4684
  "@ai-matrx/browser-audio": "latest",
4687
4685
  "@ai-matrx/canvas": "latest",
4686
+ "@ai-matrx/associations": "latest",
4688
4687
  "@ai-matrx/content-ir": "latest",
4689
- "@ai-matrx/agents": "latest",
4690
- "@ai-matrx/content-ir-react": "latest",
4691
4688
  "@ai-matrx/data": "latest",
4689
+ "@ai-matrx/content-ir-react": "latest",
4692
4690
  "@ai-matrx/design-system": "latest",
4693
4691
  "@ai-matrx/diff": "latest",
4694
4692
  "@ai-matrx/icons": "latest",
4695
4693
  "@ai-matrx/kit": "latest",
4696
4694
  "@ai-matrx/media": "latest",
4695
+ "@ai-matrx/print": "latest",
4697
4696
  "@ai-matrx/realtime": "latest",
4698
4697
  "@ai-matrx/records": "latest",
4699
4698
  "@ai-matrx/records-ui": "latest",
4700
- "@ai-matrx/print": "latest",
4701
4699
  "@ai-matrx/rich-content": "latest",
4702
- "@ai-matrx/rich-editor": "latest"
4700
+ "@ai-matrx/rich-editor": "latest",
4701
+ "@ai-matrx/agents": "latest",
4702
+ "@ai-matrx/alchemy": "latest"
4703
4703
  },
4704
4704
  "devDependencies": {
4705
4705
  "@arethetypeswrong/cli": "^0.18.5",
@@ -4818,5 +4818,5 @@
4818
4818
  "check:side-effects": "node ../scripts/check-side-effects-listed.mjs . && node ../scripts/check-side-effects-listed.mjs --self-test",
4819
4819
  "check:public-surface": "node ./scripts/sync-public-surface.mjs --check && node ./scripts/sync-public-surface.mjs --self-test"
4820
4820
  },
4821
- "gitHead": "204f87ddd70605c036eafd7a28c0e4566f50f2a3"
4821
+ "gitHead": "60c95feed80fd7dcac973ff29aec22faa19a1f2c"
4822
4822
  }