@ai-matrx/chat 0.7.18 → 0.7.20

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 (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/agents/components/chat/ChatHistorySidebar.js +3 -0
  3. package/dist/agents/components/chat/ChatSidebarMenu.js +3 -0
  4. package/dist/agents/components/chat/PinnedAgentsSection.d.ts +3 -5
  5. package/dist/agents/components/chat/PinnedAgentsSection.js +7 -4
  6. package/dist/agents/components/conversation-history/ConversationHistorySidebar.d.ts +14 -3
  7. package/dist/agents/components/conversation-history/ConversationHistorySidebar.js +57 -37
  8. package/dist/agents/components/conversation-history/ConversationSourceFilterTree.js +3 -3
  9. package/dist/agents/components/inputs/smart-input/ConversationContextChip.js +18 -7
  10. package/dist/agents/components/inputs/smart-input/ConversationContextRail.js +4 -2
  11. package/dist/agents/components/inputs/smart-input/DesktopPresenceIndicator.js +6 -0
  12. package/dist/agents/components/inputs/smart-input/composer/ComposerAgentPill.js +21 -7
  13. package/dist/agents/components/inputs/smart-input/composer/ComposerChipsRow.js +3 -3
  14. package/dist/agents/components/inputs/smart-input/composer/ComposerOutput.js +22 -15
  15. package/dist/agents/components/inputs/smart-input/composer/ComposerSplash.js +19 -3
  16. package/dist/agents/components/inputs/smart-input/composer/useComposerAgent.d.ts +24 -3
  17. package/dist/agents/components/inputs/smart-input/composer/useComposerAgent.js +32 -30
  18. package/dist/agents/components/inputs/smart-input/use-compute-target-actions.d.ts +20 -0
  19. package/dist/agents/components/inputs/smart-input/use-compute-target-actions.js +11 -0
  20. package/dist/agents/hooks/useConversationMaterialized.js +42 -9
  21. package/dist/agents/hooks/useCreatorOwnershipSync.js +7 -3
  22. package/dist/agents/hooks/useWorkingDocument.d.ts +12 -5
  23. package/dist/agents/hooks/useWorkingDocument.js +18 -6
  24. package/dist/agents/identity/agent-identity.d.ts +7 -1
  25. package/dist/agents/identity/agent-identity.js +11 -6
  26. package/dist/agents/identity/model-catalog.d.ts +7 -0
  27. package/dist/agents/identity/model-catalog.js +16 -1
  28. package/dist/agents/redux/conversation-history/slice.d.ts +2 -0
  29. package/dist/agents/redux/conversation-history/slice.js +2 -0
  30. package/dist/agents/redux/conversation-history/thunks.d.ts +2 -0
  31. package/dist/agents/redux/conversation-history/thunks.js +6 -3
  32. package/dist/agents/redux/conversation-history/types.d.ts +6 -0
  33. package/dist/agents/redux/conversation-history/types.js +1 -0
  34. package/dist/agents/redux/conversation-list/conversation-rows.d.ts +7 -0
  35. package/dist/agents/redux/conversation-list/conversation-rows.js +3 -1
  36. package/dist/agents/redux/execution-system/client-capabilities/desktop-native.provider.js +4 -1
  37. package/dist/agents/redux/execution-system/context-rules/mandate-kill-switch.d.ts +4 -0
  38. package/dist/agents/redux/execution-system/context-rules/mandate-kill-switch.js +26 -2
  39. package/dist/agents/redux/execution-system/conversations/conversations.selectors.d.ts +10 -0
  40. package/dist/agents/redux/execution-system/conversations/conversations.selectors.js +3 -0
  41. package/dist/agents/redux/execution-system/instance-user-input/useComposerDraftRestore.js +6 -1
  42. package/dist/agents/redux/execution-system/instance-working-document/scratchpad.thunks.js +9 -0
  43. package/dist/agents/redux/execution-system/thunks/launch-agent-execution.thunk.js +1 -8
  44. package/dist/agents/ui-first-tools/ui/lists/TaskPanelChip.js +4 -2
  45. package/dist/mandates/service.js +47 -32
  46. package/dist/next/navigation.js +5 -1
  47. package/package.json +6 -6
@@ -11,6 +11,9 @@ import { isMandateKey } from "@ai-matrx/agents/mandates";
11
11
  import { useAppSelector } from "../../../../../store/hooks.js";
12
12
  import { selectActiveUserName } from "../../../../../host/identity.js";
13
13
  import { useMandateSet } from "../../../../../mandates/useMandateSet.js";
14
+ import { resolveMandate } from "../../../../../mandates/service.js";
15
+ import { toast } from "../../../../../host/notify.js";
16
+ import { useIdleReady } from "@ai-matrx/kit/idle-scheduler";
14
17
  import { useSessionKnob } from "../../../../../host/prefs-react.js";
15
18
  import { cn } from "@ai-matrx/design-system";
16
19
  import { COMPOSER_KNOBS } from "./composer-mode-cookie.js";
@@ -73,9 +76,23 @@ function ComposerQuickActions({
73
76
  const organizationId = useAppSelector(selectOrganizationId);
74
77
  const actions = parseQuickActions(knob);
75
78
  const quickActionKeys = actions.map((a) => a.mandateKey);
79
+ const idle = useIdleReady();
76
80
  const mandates = useMandateSet(quickActionKeys, {
77
- enabled: Boolean(organizationId)
81
+ enabled: Boolean(organizationId) && idle
78
82
  });
83
+ const [launching, setLaunching] = useState(null);
84
+ const launch = (action) => {
85
+ const known = mandates[action.mandateKey]?.mandate?.agentId;
86
+ if (known) {
87
+ onLaunchAgent(known);
88
+ return;
89
+ }
90
+ setLaunching(action.mandateKey);
91
+ resolveMandate(action.mandateKey).then((mandate) => onLaunchAgent(mandate.agentId)).catch((error) => {
92
+ console.error(`[composer] quick action ${action.mandateKey} could not resolve:`, error);
93
+ toast.error(`"${action.label}" is not available yet`);
94
+ }).finally(() => setLaunching(null));
95
+ };
79
96
  const scrollerRef = useRef(null);
80
97
  const [canScroll, setCanScroll] = useState(false);
81
98
  useEffect(() => {
@@ -105,9 +122,8 @@ function ComposerQuickActions({
105
122
  className: "flex min-w-0 flex-nowrap gap-2 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
106
123
  children: actions.map((action) => {
107
124
  const state = mandates[action.mandateKey];
108
- const agentId = state?.mandate?.agentId ?? null;
109
125
  const unavailable = Boolean(state && !state.loading && state.error);
110
- return /* @__PURE__ */ jsx(Button, { variant: "outline", disabled: !agentId, title: unavailable ? `"${action.label}" is not available yet \u2014 its agent has not been assigned (${action.mandateKey}).` : void 0, onClick: () => agentId && onLaunchAgent(agentId), className: "shrink-0", children: action.label }, `${action.mandateKey}:${action.label}`);
126
+ return /* @__PURE__ */ jsx(Button, { variant: "outline", disabled: unavailable || launching === action.mandateKey, title: unavailable ? `"${action.label}" is not available yet \u2014 its agent has not been assigned (${action.mandateKey}).` : void 0, onClick: () => launch(action), className: "shrink-0", children: action.label }, `${action.mandateKey}:${action.label}`);
111
127
  })
112
128
  }
113
129
  ),
@@ -18,9 +18,30 @@ export interface ComposerAgentInfo {
18
18
  /** The Matrx Chat agent (`chat.cx_default`) — shown as "Custom Agent". */
19
19
  customAgentId: string | null;
20
20
  isCustom: boolean;
21
- preset: ComposerPreset | null;
22
- presets: ComposerPreset[];
23
21
  }
24
22
  /** The model this conversation will run on: its override, else the agent's own. */
25
23
  export declare function useEffectiveModelId(conversationId: string): string | null;
26
- export declare function useComposerAgent(conversationId: string): ComposerAgentInfo;
24
+ /**
25
+ * The pill's CLOSED state (2026-10-09: /chat/new loads nearly instantly) — read
26
+ * from what the run already loaded (the run tier names the agent) plus ONE
27
+ * model's record and classes. Nothing here reads a list: the agent catalogue,
28
+ * the model options, every model's classes and the chat presets load when a
29
+ * person opens the menu (`useComposerPresets`).
30
+ *
31
+ * `engaged` = the menu is (or was) open. The `chat.cx_default` job ("Custom
32
+ * Agent") is asked then, or once the page is idle — whichever comes first — so
33
+ * the closed label can say "Custom Agent" without the ask holding up paint.
34
+ */
35
+ export declare function useComposerAgent(conversationId: string, options?: {
36
+ engaged?: boolean;
37
+ }): ComposerAgentInfo;
38
+ /**
39
+ * Chat mode's presets (`chat-agent` tagged agents, each with its model) — the
40
+ * agent catalogue and the model options, read when the menu that lists them
41
+ * MOUNTS (it mounts only while open). Both reads are shared and deduped with
42
+ * every other picker on the page.
43
+ */
44
+ export declare function useComposerPresets(agentId: string | null): {
45
+ preset: ComposerPreset | null;
46
+ presets: ComposerPreset[];
47
+ };
@@ -2,19 +2,16 @@
2
2
  import { useEffect } from "react";
3
3
  import { MANDATE_KEYS } from "@ai-matrx/agents/mandates";
4
4
  import { useAgentCatalogRows } from "@ai-matrx/agents/catalog/react";
5
- import { useAppDispatch, useAppSelector } from "../../../../../store/hooks.js";
5
+ import { useAppSelector } from "../../../../../store/hooks.js";
6
6
  import { useMandate } from "../../../../../mandates/useMandate.js";
7
7
  import { selectAgentIdFromInstance } from "../../../../redux/execution-system/conversations/conversations.selectors.js";
8
8
  import {
9
9
  selectAgentModelId
10
10
  } from "../../../../redux/agent-definition/selectors.js";
11
11
  import { selectInstanceOverrideState } from "../../../../redux/execution-system/instance-model-overrides/instance-model-overrides.selectors.js";
12
- import {
13
- selectModelLabelById,
14
- selectModelLabelWithClass
15
- } from "@ai-matrx/agents/models";
16
- import { useModelOptions } from "../../../../identity/model-catalog.js";
17
- import { useModelClassLabels, useModelRecords } from "../../../../identity/model-catalog.js";
12
+ import { selectModelLabelById } from "@ai-matrx/agents/models";
13
+ import { useModelLabelForOneModel, useModelOptions, useModelRecords } from "../../../../identity/model-catalog.js";
14
+ import { useIdleReady } from "@ai-matrx/kit/idle-scheduler";
18
15
  import { useEffectiveClassPin } from "../../../../redux/execution-system/instance-model-overrides/useEffectiveClassPin.js";
19
16
  import { ensureAgentCatalog, useAgentName } from "../../../../identity/agent-identity.js";
20
17
  const CHAT_AGENT_TAG = "chat-agent";
@@ -36,34 +33,23 @@ function useEffectiveModelId(conversationId) {
36
33
  const overrideModel = overrideState?.overrides?.model ?? null;
37
34
  return (typeof overrideModel === "string" ? overrideModel : null) ?? (typeof baseModel === "string" ? baseModel : null) ?? agentModelId ?? null;
38
35
  }
39
- function useComposerAgent(conversationId) {
40
- const dispatch = useAppDispatch();
41
- useModelOptions();
42
- useEffect(() => {
43
- void ensureAgentCatalog();
44
- }, [dispatch]);
36
+ function useComposerAgent(conversationId, options) {
45
37
  const agentId = useAppSelector(selectAgentIdFromInstance(conversationId)) ?? null;
46
38
  const agentName = useAgentName(agentId) ?? null;
47
39
  const effectiveModelId = useEffectiveModelId(conversationId);
48
- useModelClassLabels();
49
40
  const classPins = useEffectiveClassPin(conversationId);
50
41
  const effectivePin = classPins.effectivePin ?? null;
51
42
  const agentOwnPin = classPins.basePin ?? null;
52
- const effectiveModelLabel = useModelRecords((state) => selectModelLabelWithClass(state, effectiveModelId, effectivePin)) ?? null;
43
+ const effectiveModelLabel = useModelLabelForOneModel(effectiveModelId, effectivePin) ?? null;
53
44
  const agentOwnModelId = useAgentOwnModelId(conversationId);
54
- const agentModelLabel = useModelRecords((state) => selectModelLabelWithClass(state, agentOwnModelId, agentOwnPin)) ?? null;
55
- const { mandate: customMandate } = useMandate(MANDATE_KEYS.chat__cx_default, { optional: true });
45
+ const agentModelLabel = useModelLabelForOneModel(agentOwnModelId, agentOwnPin) ?? null;
46
+ const conversationMandateKey = useAppSelector(
47
+ (state) => state.conversations.byConversationId[conversationId]?.mandateKey ?? null
48
+ );
49
+ const idle = useIdleReady();
50
+ const askCustom = Boolean(options?.engaged) || idle;
51
+ const { mandate: customMandate } = useMandate(askCustom ? MANDATE_KEYS.chat__cx_default : "", { optional: true });
56
52
  const customAgentId = customMandate?.agentId ?? null;
57
- const rows = useAgentCatalogRows();
58
- const presetRows = rows.filter(isPresetRow);
59
- const modelLabels = useModelRecords(
60
- (state) => presetRows.map((row) => selectModelLabelById(state, row.modelId) ?? null).join("\0")
61
- ).split("\0");
62
- const presets = presetRows.map((row, index) => ({
63
- id: row.id,
64
- name: row.name ?? "Untitled agent",
65
- modelLabel: modelLabels[index] || null
66
- }));
67
53
  return {
68
54
  agentId,
69
55
  agentName,
@@ -74,13 +60,29 @@ function useComposerAgent(conversationId) {
74
60
  ),
75
61
  agentModelLabel,
76
62
  customAgentId,
77
- isCustom: Boolean(agentId && customAgentId && agentId === customAgentId),
78
- preset: presets.find((p) => p.id === agentId) ?? null,
79
- presets
63
+ isCustom: conversationMandateKey === MANDATE_KEYS.chat__cx_default || Boolean(agentId && customAgentId && agentId === customAgentId)
80
64
  };
81
65
  }
66
+ function useComposerPresets(agentId) {
67
+ useModelOptions();
68
+ useEffect(() => {
69
+ void ensureAgentCatalog();
70
+ }, []);
71
+ const rows = useAgentCatalogRows();
72
+ const presetRows = rows.filter(isPresetRow);
73
+ const modelLabels = useModelRecords(
74
+ (state) => presetRows.map((row) => selectModelLabelById(state, row.modelId) ?? null).join("\0")
75
+ ).split("\0");
76
+ const presets = presetRows.map((row, index) => ({
77
+ id: row.id,
78
+ name: row.name ?? "Untitled agent",
79
+ modelLabel: modelLabels[index] || null
80
+ }));
81
+ return { preset: presets.find((p) => p.id === agentId) ?? null, presets };
82
+ }
82
83
  export {
83
84
  CHAT_AGENT_TAG,
84
85
  useComposerAgent,
86
+ useComposerPresets,
85
87
  useEffectiveModelId
86
88
  };
@@ -14,6 +14,26 @@ export declare function pickComputeLensTargets(targets: ComputeTarget[], boundRo
14
14
  totalCount: number;
15
15
  };
16
16
  export declare function useSandboxBindingBlocked(conversationId: string): boolean;
17
+ /**
18
+ * The bound box ONLY — named from the conversation's record, with no targets
19
+ * list. For a surface that shows what this chat runs on at rest (the Cloud
20
+ * chip, the bound-box button): the list of every computer and sandbox
21
+ * (`GET /api/compute-targets`) is read when a picker OPENS — the panels that
22
+ * offer a choice call `useComputeTargetActions` themselves, and they mount on
23
+ * open (owner, 2026-10-09: nothing non-essential at startup). The view's
24
+ * `state` stays `"checking"` here: liveness comes with the list.
25
+ */
26
+ export declare function useComputeBinding(conversationId: string): {
27
+ boundView: import("../../../../compute/targets.js").BoundTargetView | null;
28
+ sandboxBlocked: boolean;
29
+ hasBinding: boolean;
30
+ };
31
+ /**
32
+ * Everything a compute PICKER needs: the bound box, every target this person
33
+ * can use, and the bind. Reads the targets list — call it only from a surface
34
+ * the person opened (a popover / menu panel) or one that shows a bound box's
35
+ * liveness; an at-rest chip uses `useComputeBinding`.
36
+ */
17
37
  export declare function useComputeTargetActions(conversationId: string): {
18
38
  loading: boolean;
19
39
  sandboxBlocked: boolean;
@@ -43,6 +43,16 @@ function useSandboxBindingBlocked(conversationId) {
43
43
  const chatIncognito = useAppSelector(selectChatIncognitoActive);
44
44
  return isEphemeral || chatIncognito && sourceFeature === "chat-route";
45
45
  }
46
+ function useComputeBinding(conversationId) {
47
+ const binding = useVerifiedSandboxBinding(conversationId);
48
+ const sandboxBlocked = useSandboxBindingBlocked(conversationId);
49
+ const boundView = resolveBoundTargetView({
50
+ ref: binding.ref,
51
+ status: binding.status,
52
+ targets: null
53
+ });
54
+ return { boundView, sandboxBlocked, hasBinding: !!binding.ref };
55
+ }
46
56
  function useComputeTargetActions(conversationId) {
47
57
  const dispatch = useAppDispatch();
48
58
  const { data, loading, refetch } = useComputeTargets();
@@ -125,6 +135,7 @@ export {
125
135
  computeTargetIconColor,
126
136
  computeTargetKindLabel,
127
137
  pickComputeLensTargets,
138
+ useComputeBinding,
128
139
  useComputeTargetActions,
129
140
  useSandboxBindingBlocked
130
141
  };
@@ -4,6 +4,7 @@ import { useAppSelector } from "../../store/hooks.js";
4
4
  import { selectPrimaryRequest } from "../redux/execution-system/active-requests/active-requests.selectors.js";
5
5
  import { waitForConversationPersisted } from "../redux/execution-system/conversations/conversation-persistence.js";
6
6
  import { selectUserId } from "../../host/identity.js";
7
+ import { selectIsDraftConversation } from "../redux/execution-system/conversations/conversations.selectors.js";
7
8
  const materializedIds = /* @__PURE__ */ new Set();
8
9
  function materializedKey(userId, conversationId) {
9
10
  return `${userId}:${conversationId}`;
@@ -21,12 +22,48 @@ function markMaterialized(userId, conversationId) {
21
22
  materializedIds.add(key);
22
23
  for (const listener of cacheListeners) listener();
23
24
  }
25
+ const probesInFlight = /* @__PURE__ */ new Map();
26
+ function joinProbe(conversationId, key, poll) {
27
+ const probeKey = `${key}:${poll ? "poll" : "once"}`;
28
+ let probe = probesInFlight.get(probeKey);
29
+ if (!probe) {
30
+ const controller = new AbortController();
31
+ const promise = waitForConversationPersisted(conversationId, {
32
+ signal: controller.signal,
33
+ // Existing/reloaded conversations need one cheap proof read. A live
34
+ // stream keeps waiting because its first atomic commit is still pending.
35
+ timeoutMs: poll ? void 0 : 0
36
+ }).finally(() => {
37
+ if (probesInFlight.get(probeKey)?.promise === promise) {
38
+ probesInFlight.delete(probeKey);
39
+ }
40
+ });
41
+ probe = { promise, controller, waiters: 0 };
42
+ probesInFlight.set(probeKey, probe);
43
+ }
44
+ const joined = probe;
45
+ joined.waiters += 1;
46
+ let left = false;
47
+ return {
48
+ promise: joined.promise,
49
+ leave: () => {
50
+ if (left) return;
51
+ left = true;
52
+ joined.waiters -= 1;
53
+ if (joined.waiters <= 0) {
54
+ joined.controller.abort();
55
+ if (probesInFlight.get(probeKey) === joined) probesInFlight.delete(probeKey);
56
+ }
57
+ }
58
+ };
59
+ }
24
60
  function useConversationMaterialized(conversationId) {
25
61
  const userId = useAppSelector(selectUserId);
26
62
  const status = useAppSelector(
27
63
  (state) => conversationId ? selectPrimaryRequest(conversationId)(state)?.status : void 0
28
64
  );
29
65
  const shouldPoll = status === "streaming" || status === "awaiting-tools" || status === "complete";
66
+ const isDraft = useAppSelector(selectIsDraftConversation(conversationId));
30
67
  const confirmedByCache = useSyncExternalStore(
31
68
  subscribeToCache,
32
69
  () => userId && conversationId ? materializedIds.has(materializedKey(userId, conversationId)) : false,
@@ -34,19 +71,15 @@ function useConversationMaterialized(conversationId) {
34
71
  );
35
72
  useEffect(() => {
36
73
  if (!userId || !conversationId) return;
74
+ if (isDraft && !shouldPoll) return;
37
75
  const key = materializedKey(userId, conversationId);
38
76
  if (materializedIds.has(key)) return;
39
- const controller = new AbortController();
40
- void waitForConversationPersisted(conversationId, {
41
- signal: controller.signal,
42
- // Existing/reloaded conversations need one cheap proof read. A live
43
- // stream keeps waiting because its first atomic commit is still pending.
44
- timeoutMs: shouldPoll ? void 0 : 0
45
- }).then((persisted) => {
77
+ const probe = joinProbe(conversationId, key, shouldPoll);
78
+ void probe.promise.then((persisted) => {
46
79
  if (persisted) markMaterialized(userId, conversationId);
47
80
  });
48
- return () => controller.abort();
49
- }, [conversationId, shouldPoll, userId]);
81
+ return probe.leave;
82
+ }, [conversationId, isDraft, shouldPoll, userId]);
50
83
  return confirmedByCache;
51
84
  }
52
85
  export {
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { useEffect } from "react";
3
+ import { whenPageIdle } from "@ai-matrx/kit/idle-scheduler";
3
4
  import { useAppDispatch, useAppSelector } from "../../store/hooks.js";
4
5
  import { setIsCreator } from "../../host/prefs.js";
5
6
  import { adminSeatOpen } from "../../host/admin-seat.js";
@@ -11,9 +12,12 @@ function useCreatorOwnershipSync(agentId) {
11
12
  (state) => agentId ? selectAgentIsOwner(state, agentId) : null
12
13
  );
13
14
  useEffect(() => {
14
- if (agentId && isOwnerFlag === null) {
15
- void dispatch(fetchAgentAccessLevel(agentId));
16
- }
15
+ if (!agentId || isOwnerFlag !== null) return void 0;
16
+ const controller = new AbortController();
17
+ void whenPageIdle(controller.signal).then((idle) => {
18
+ if (idle) void dispatch(fetchAgentAccessLevel(agentId));
19
+ });
20
+ return () => controller.abort();
17
21
  }, [dispatch, agentId, isOwnerFlag]);
18
22
  const adminSeat = useAppSelector((state) => adminSeatOpen(state));
19
23
  const isOwner = !!agentId && (isOwnerFlag === true || adminSeat);
@@ -18,11 +18,18 @@ export declare function useWorkingDocumentContextSync(conversationId: string, ki
18
18
  export declare function useScratchpadContextSync(conversationId: string): void;
19
19
  /**
20
20
  * Always-on per-conversation bridge: restore the conversation's persisted
21
- * WORKING document + attached scratchpads, resolve the user's global ACTIVE
22
- * scratchpad, then keep the conversation's document context entries current.
23
- * Mount this once where a conversation is always present (the Smart Input), so
24
- * the agent receives the current documents regardless of which editor (if any)
25
- * is open.
21
+ * WORKING document + attached scratchpads, then keep the conversation's
22
+ * document context entries current. Mount this once where a conversation is
23
+ * always present (the Smart Input), so the agent receives the current
24
+ * documents regardless of which editor (if any) is open.
25
+ *
26
+ * Nothing is read at mount that cannot change what is sent (owner, 2026-10-09:
27
+ * the page loads near-instantly):
28
+ * • a DRAFT (minted here, not yet sent) has no edges to restore — the
29
+ * restore runs the moment the first send makes the conversation real;
30
+ * • the user's global ACTIVE scratchpad is read only when this conversation
31
+ * sends it (its scratch gate is on) or a scratchpad surface opens (those
32
+ * surfaces hydrate it themselves).
26
33
  */
27
34
  export declare function useConversationDocumentsBridge(conversationId: string): void;
28
35
  export interface UseWorkingDocumentResult {
@@ -69,7 +69,10 @@ import {
69
69
  getCxWorkingDocumentById,
70
70
  updateCxWorkingDocumentTitle
71
71
  } from "../redux/execution-system/instance-working-document/cx-working-document.service.js";
72
- import { selectIsCacheOnly } from "../redux/execution-system/conversations/conversations.selectors.js";
72
+ import {
73
+ selectIsCacheOnly,
74
+ selectIsDraftConversation
75
+ } from "../redux/execution-system/conversations/conversations.selectors.js";
73
76
  import {
74
77
  useOpenScratchpadPanel,
75
78
  useOpenWorkingDocumentPanel
@@ -332,8 +335,9 @@ function useScratchpadContextSync(conversationId) {
332
335
  function useConversationDocumentsBridge(conversationId) {
333
336
  const dispatch = useAppDispatch();
334
337
  const userId = useAppSelector(selectUserId);
338
+ const isDraft = useAppSelector(selectIsDraftConversation(conversationId));
335
339
  useEffect(() => {
336
- if (!userId) return;
340
+ if (!userId || isDraft) return;
337
341
  const ownsHydration = acquireDocumentBridge(conversationId);
338
342
  if (!ownsHydration) {
339
343
  return () => releaseDocumentBridge(conversationId);
@@ -344,14 +348,22 @@ function useConversationDocumentsBridge(conversationId) {
344
348
  "Couldn't restore this conversation's working document \u2014 the agent won't see it until you reopen the conversation."
345
349
  );
346
350
  });
347
- void dispatch(hydrateActiveScratchpadThunk()).unwrap().then(
348
- () => dispatch(hydrateAttachedScratchpadsThunk({ conversationId })).unwrap()
349
- ).catch((err) => {
351
+ void dispatch(hydrateAttachedScratchpadsThunk({ conversationId })).unwrap().catch((err) => {
350
352
  console.error("[scratchpad] bridge hydrate failed", err);
351
353
  toast.error("Couldn't restore your scratchpad for this conversation.");
352
354
  });
353
355
  return () => releaseDocumentBridge(conversationId);
354
- }, [dispatch, conversationId, userId]);
356
+ }, [dispatch, conversationId, userId, isDraft]);
357
+ const scratchGateOn = useAppSelector(
358
+ selectWorkingDocEnabled(conversationId, "scratch")
359
+ );
360
+ useEffect(() => {
361
+ if (!userId || !scratchGateOn) return;
362
+ void dispatch(hydrateActiveScratchpadThunk()).unwrap().catch((err) => {
363
+ console.error("[scratchpad] bridge hydrate failed", err);
364
+ toast.error("Couldn't load your scratchpad \u2014 the agent won't see it this turn.");
365
+ });
366
+ }, [dispatch, userId, scratchGateOn]);
355
367
  useWorkingDocumentContextSync(conversationId, "working");
356
368
  useScratchpadContextSync(conversationId);
357
369
  }
@@ -12,6 +12,10 @@
12
12
  * useAgentView(id) — the record, completed from the catalog row (derived, no copy)
13
13
  * readAgentName / readAgentDescription / readAgentView(state, id) — thunks, callbacks
14
14
  * ensureAgentCatalog() / isAgentCatalogStale() — load the list
15
+ *
16
+ * The name/description hooks read the catalogue only when the record has not read
17
+ * the field and is not reading it (`catalogNeededFor`): the list loads when a
18
+ * picker opens, never to print one name.
15
19
  */
16
20
  import type { AgentSummary } from "@ai-matrx/agents/catalog";
17
21
  import type { ChatRootState } from "../../store/root-state.js";
@@ -32,7 +36,9 @@ export interface AgentIdentity {
32
36
  * One agent's catalog row, loading the catalog (then a server search) when it is missing — chat's
33
37
  * own binding of the store (the same contract as `@ai-matrx/agents/catalog/react` `useAgentSummary`).
34
38
  */
35
- export declare function useAgentSummaryRow(agentId: string | null | undefined): AgentSummary | undefined;
39
+ export declare function useAgentSummaryRow(agentId: string | null | undefined, options?: {
40
+ load?: boolean;
41
+ }): AgentSummary | undefined;
36
42
  type MaybeRecord = AgentDefinitionRecord | undefined;
37
43
  /**
38
44
  * The agent's record completed from its catalog row: every field the record has READ wins; the
@@ -39,7 +39,8 @@ function isAgentCatalogStale() {
39
39
  }
40
40
  }
41
41
  const noopSubscribe = () => () => void 0;
42
- function useAgentSummaryRow(agentId) {
42
+ function useAgentSummaryRow(agentId, options) {
43
+ const load = options?.load ?? true;
43
44
  const catalog = useCatalog();
44
45
  const row = useSyncExternalStore(
45
46
  catalog ? catalog.subscribe : noopSubscribe,
@@ -48,7 +49,7 @@ function useAgentSummaryRow(agentId) {
48
49
  );
49
50
  const present = row !== void 0;
50
51
  useEffect(() => {
51
- if (!catalog || !agentId || present) return;
52
+ if (!catalog || !agentId || present || !load) return;
52
53
  let cancelled = false;
53
54
  void catalog.ensureLoaded().then(() => {
54
55
  if (cancelled || catalog.getState().byId[agentId]) return;
@@ -57,9 +58,13 @@ function useAgentSummaryRow(agentId) {
57
58
  return () => {
58
59
  cancelled = true;
59
60
  };
60
- }, [catalog, agentId, present]);
61
+ }, [catalog, agentId, present, load]);
61
62
  return row;
62
63
  }
64
+ function catalogNeededFor(record, fields) {
65
+ if (record?._loading) return false;
66
+ return !fields.every((field) => loaded(record, field));
67
+ }
63
68
  function loaded(record, field) {
64
69
  return !!record?._loadedFields && hasField(record._loadedFields, field);
65
70
  }
@@ -110,18 +115,18 @@ function readAgentView(state, agentId) {
110
115
  return agentViewOf(recordOf(state, agentId), catalogRow(agentId));
111
116
  }
112
117
  function useAgentName(agentId) {
113
- const row = useAgentSummaryRow(agentId);
114
118
  const record = useAppSelector((s) => recordOf(s, agentId));
119
+ const row = useAgentSummaryRow(agentId, { load: catalogNeededFor(record, ["name"]) });
115
120
  return nameOf(record, row);
116
121
  }
117
122
  function useAgentDescription(agentId) {
118
- const row = useAgentSummaryRow(agentId);
119
123
  const record = useAppSelector((s) => recordOf(s, agentId));
124
+ const row = useAgentSummaryRow(agentId, { load: catalogNeededFor(record, ["description"]) });
120
125
  return descriptionOf(record, row);
121
126
  }
122
127
  function useAgentIdentity(agentId) {
123
- const summary = useAgentSummaryRow(agentId);
124
128
  const record = useAppSelector((s) => recordOf(s, agentId));
129
+ const summary = useAgentSummaryRow(agentId, { load: catalogNeededFor(record, ["name", "description"]) });
125
130
  return { name: nameOf(record, summary), description: descriptionOf(record, summary), summary };
126
131
  }
127
132
  function readAgentName(state, agentId) {
@@ -44,3 +44,10 @@ export declare function useModelOptions(): {
44
44
  };
45
45
  /** One model's FULL record (controls included), or undefined until it has loaded. */
46
46
  export declare function useModelFull(modelId: string | null | undefined): ModelRecord | undefined;
47
+ /**
48
+ * The label a CLOSED control shows for the model a conversation runs on —
49
+ * "Qwen3.8 27B · Matrx Lightning" — from ONE model's reads: its record (the same
50
+ * deduped read `useModelFull` makes) and its own classes. Never the options
51
+ * list or every model's classes; those load when a picker opens.
52
+ */
53
+ export declare function useModelLabelForOneModel(modelId: string | null | undefined, offeringId: string | null | undefined): string | undefined;
@@ -110,11 +110,25 @@ function useModelFull(modelId) {
110
110
  const isFull = useModelRecords((s) => selectModelFullyLoaded(s, modelId));
111
111
  useEffect(() => {
112
112
  if (!chatHostHasDatabase()) return;
113
- void getModelRecords().loadOptions();
114
113
  if (modelId && !isFull) void getModelRecords().loadModel(modelId);
115
114
  }, [modelId, isFull]);
116
115
  return isFull ? record : void 0;
117
116
  }
117
+ function useModelLabelForOneModel(modelId, offeringId) {
118
+ const label = useModelRecords((s) => selectModelLabelWithClass(s, modelId, offeringId));
119
+ const known = useModelRecords((s) => Boolean(selectModelLabelById(s, modelId)));
120
+ useEffect(() => {
121
+ if (!modelId || !chatHostHasDatabase()) return;
122
+ const records = getModelRecords();
123
+ if (!known) {
124
+ void records.loadModel(modelId).then(() => {
125
+ if (!selectModelLabelById(records.getState(), modelId)) void records.loadIdentity(modelId);
126
+ });
127
+ }
128
+ void records.loadModelClasses(modelId);
129
+ }, [modelId, known]);
130
+ return label;
131
+ }
118
132
  export {
119
133
  getModelCatalog,
120
134
  getModelRecords,
@@ -123,6 +137,7 @@ export {
123
137
  useModelClassLabels,
124
138
  useModelFull,
125
139
  useModelLabel,
140
+ useModelLabelForOneModel,
126
141
  useModelLabelWithClass,
127
142
  useModelOptions,
128
143
  useModelRecord,
@@ -16,6 +16,8 @@ export declare const configureScope: import("@reduxjs/toolkit").ActionCreatorWit
16
16
  includeOriginClasses?: string[];
17
17
  grouping?: HistoryGrouping;
18
18
  pageSize?: number;
19
+ /** Slim row read (`ConversationHistoryScopeState.nameRowsOnly`). */
20
+ nameRowsOnly?: boolean;
19
21
  /** When true, drops items/offset so a fresh fetch repopulates. */
20
22
  reset?: boolean;
21
23
  }, "conversationHistory/configureScope">, setScopeAgentIds: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
@@ -58,6 +58,7 @@ const slice = createSlice({
58
58
  includeOriginClasses,
59
59
  grouping,
60
60
  pageSize,
61
+ nameRowsOnly,
61
62
  reset
62
63
  } = action.payload;
63
64
  const scope = ensureScope(state, scopeId);
@@ -79,6 +80,7 @@ const slice = createSlice({
79
80
  }
80
81
  if (grouping !== void 0) scope.grouping = grouping;
81
82
  if (pageSize !== void 0) scope.pageSize = pageSize;
83
+ if (nameRowsOnly !== void 0) scope.nameRowsOnly = nameRowsOnly;
82
84
  if (reset) invalidateScopeWindow(scope);
83
85
  },
84
86
  /**
@@ -34,6 +34,8 @@ export interface FetchConversationHistoryArgs {
34
34
  includeEmptySource?: boolean;
35
35
  /** Overrides the scope's stored pageSize for this fetch. */
36
36
  pageSize?: number;
37
+ /** Overrides the scope's stored `nameRowsOnly` (slim row read). */
38
+ nameRowsOnly?: boolean;
37
39
  /**
38
40
  * When true (default), replaces existing items with the freshly-fetched
39
41
  * first page. When false, appends a new page using the scope's `offset`.
@@ -2,6 +2,7 @@ import { createAsyncThunk } from "@reduxjs/toolkit";
2
2
  import { supabase } from "../../../host/db.js";
3
3
  import { applyFavoritesFromUes } from "../conversation-list/conversation-list.thunks.js";
4
4
  import {
5
+ CONVERSATION_NAME_ROW_COLUMNS,
5
6
  CONVERSATION_ROW_COLUMNS,
6
7
  mapConversationDbRow
7
8
  } from "../conversation-list/conversation-rows.js";
@@ -31,7 +32,7 @@ const fetchConversationHistory = createAsyncThunk(
31
32
  const replace = args.replace ?? true;
32
33
  const state = getState();
33
34
  const existing = state.conversationHistory.scopes[args.scopeId];
34
- if (!existing || args.agentIds !== void 0 || args.excludeSourceFeatures !== void 0 || args.includeSourceFeatures !== void 0 || args.includeSourceApps !== void 0 || args.includeEmptySource !== void 0 || args.pageSize !== void 0) {
35
+ if (!existing || args.agentIds !== void 0 || args.excludeSourceFeatures !== void 0 || args.includeSourceFeatures !== void 0 || args.includeSourceApps !== void 0 || args.includeEmptySource !== void 0 || args.pageSize !== void 0 || args.nameRowsOnly !== void 0) {
35
36
  dispatch(
36
37
  configureScope({
37
38
  scopeId: args.scopeId,
@@ -40,7 +41,8 @@ const fetchConversationHistory = createAsyncThunk(
40
41
  includeSourceFeatures: args.includeSourceFeatures ?? existing?.includeSourceFeatures ?? [],
41
42
  includeSourceApps: args.includeSourceApps ?? existing?.includeSourceApps ?? [],
42
43
  includeEmptySource: args.includeEmptySource ?? existing?.includeEmptySource ?? false,
43
- pageSize: args.pageSize ?? existing?.pageSize ?? defaultScopeState.pageSize
44
+ pageSize: args.pageSize ?? existing?.pageSize ?? defaultScopeState.pageSize,
45
+ nameRowsOnly: args.nameRowsOnly ?? existing?.nameRowsOnly ?? defaultScopeState.nameRowsOnly
44
46
  })
45
47
  );
46
48
  }
@@ -66,7 +68,8 @@ const fetchConversationHistory = createAsyncThunk(
66
68
  );
67
69
  return { scopeId: args.scopeId, items: [], hasMore: false, nextOffset: 0, replace };
68
70
  }
69
- let query = supabase.schema("chat").from("conversation").select(CONVERSATION_ROW_COLUMNS).is("deleted_at", null).eq("is_ephemeral", false).order("updated_at", { ascending: false }).range(offset, offset + pageSize - 1);
71
+ const columns = scope.nameRowsOnly ? CONVERSATION_NAME_ROW_COLUMNS : CONVERSATION_ROW_COLUMNS;
72
+ let query = supabase.schema("chat").from("conversation").select(columns).is("deleted_at", null).eq("is_ephemeral", false).order("updated_at", { ascending: false }).range(offset, offset + pageSize - 1);
70
73
  if (viewerId && !(agentIds.length > 0 && adminSeatOpen(getState()))) {
71
74
  query = query.eq("created_by", viewerId);
72
75
  }
@@ -97,6 +97,12 @@ export interface ConversationHistoryScopeState {
97
97
  grouping: HistoryGrouping;
98
98
  /** Page size for range-based pagination. */
99
99
  pageSize: number;
100
+ /**
101
+ * Read only what a history ROW shows (`CONVERSATION_NAME_ROW_COLUMNS`: name,
102
+ * time, provenance for its menu) — the /chat sidebar's slim read. Rows from
103
+ * it carry no description / message count / model.
104
+ */
105
+ nameRowsOnly: boolean;
100
106
  /** How many items we've fetched so far (the next `range` offset). */
101
107
  offset: number;
102
108
  /** Items in display order (already sorted by `updated_at` desc). */
@@ -13,6 +13,7 @@ const defaultScopeState = {
13
13
  searchTerm: "",
14
14
  grouping: "date",
15
15
  pageSize: 30,
16
+ nameRowsOnly: false,
16
17
  offset: 0,
17
18
  items: [],
18
19
  hasMore: false,