@ai-matrx/chat 0.7.23 → 0.7.24

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,8 +1,23 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.7.24
4
+
5
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
6
+ `git diff npm/chat/v0.7.23..npm/chat/v0.7.24 -- apps/shared/chat`).
7
+ No source changes intended and no consumer action required.
8
+
3
9
  ## 0.7.23
4
10
  ### Unreleased
5
11
 
12
+ ### The Form inputs layout folds, it never disappears
13
+
14
+ - **`AgentVariableForm`: the "Fill in the details below" header folds the fields and opens them again.**
15
+ It used to write the host's `showVariablePanel` flag, so one click removed the whole form, header
16
+ included — a host with no variables button of its own (the form-style composer, a Board agent-form
17
+ tile) could never get the fields back. The fold is the form's own state; `showVariablePanel` stays the
18
+ host's hide and the composer button's. Guard: `form-fold-never-removes-the-form.test.tsx`.
19
+ Consumer action: none.
20
+
6
21
  ### A run parked on the person waits for them (lane F15)
7
22
 
8
23
  - **`runHeadlessAgentJson` / `adoptHeadlessAgentJson` / `continueAgentJson`: waiting on the person is a
@@ -30,6 +45,17 @@ No source changes intended and no consumer action required.
30
45
  ## 0.7.21
31
46
  ### Unreleased
32
47
 
48
+ ### /chat/new: the last two startup costs
49
+
50
+ - **Pinned agents read only the pins, only on screen.** `PinnedAgentsSection` waits for
51
+ `useInView` (never from a closed mobile sheet or collapsed rail), then calls the catalog's
52
+ `ensureFavoriteRows()` — `ues_list` + `agx_get_list_full?id=in.(…)` — instead of loading the
53
+ whole agent catalogue after page idle. It shows a loading skeleton and a retry on failure.
54
+ - **The splash paints its final footprint.** The server-painted composer shell carries the
55
+ composer's placeholder (no pulse), the landing no longer fades in from opacity 0, and the
56
+ quick-action row keeps its skeleton until the organization is set instead of vanishing. On a
57
+ phone the textarea placeholder was the page's Largest Contentful Paint at 5.7–15 s.
58
+
33
59
  ### /chat/new: the live residuals of the startup-read work
34
60
 
35
61
  - **A minted id is a draft before its record exists.** `generateConversationId` remembers the ids this tab
@@ -20,6 +20,15 @@ interface NewChatGreetingProps {
20
20
  * agent's fresh-chat route, never a launch with a resolved id.
21
21
  */
22
22
  export declare function NewChatGreeting({ sourceConversationId, surfaceKey, composer, }: NewChatGreetingProps): import("react").JSX.Element;
23
- /** The splash composer's footprint while its conversation is created. */
23
+ /**
24
+ * The splash composer as the server paints it, before the page is live: the
25
+ * real card's box and its placeholder in the real textarea's type and inset.
26
+ *
27
+ * THE PLACEHOLDER PAINTS WITH THE PAGE (2026-10-09). It is the largest text on
28
+ * a phone's /chat/new (larger than the greeting), so a shell without it made
29
+ * the live textarea's placeholder the page's Largest Contentful Paint seconds
30
+ * later. Same words, same size: the live box replaces it without a new paint
31
+ * winning. No pulse — an opacity animation would hide the text from that count.
32
+ */
24
33
  export declare function ChatSplashComposerShell(): import("react").JSX.Element;
25
34
  export {};
@@ -11,6 +11,7 @@ import {
11
11
  ComposerQuickActions
12
12
  } from "../inputs/smart-input/composer/ComposerSplash.js";
13
13
  import { IntelligenceIndicator } from "../../../surfaces/runtime/intelligence.js";
14
+ const SPLASH_PLACEHOLDER = "How can I help you today?";
14
15
  function NewChatGreeting({
15
16
  sourceConversationId,
16
17
  surfaceKey,
@@ -35,7 +36,7 @@ function NewChatGreeting({
35
36
  {
36
37
  conversationId: sourceConversationId,
37
38
  surfaceKey,
38
- composer: { ...composer, size: "splash", placeholder: "How can I help you today?" }
39
+ composer: { ...composer, size: "splash", placeholder: SPLASH_PLACEHOLDER }
39
40
  }
40
41
  ) : /* @__PURE__ */ jsx(ChatSplashComposerShell, {}),
41
42
  /* @__PURE__ */ jsx(
@@ -60,7 +61,8 @@ function ChatSplashComposerShell() {
60
61
  "div",
61
62
  {
62
63
  "aria-hidden": true,
63
- className: "h-[132px] w-full max-w-[760px] animate-pulse rounded-[22px] border border-border bg-card"
64
+ className: "h-[132px] w-full max-w-[760px] rounded-[22px] border border-border bg-card p-2 shadow-[0_2px_10px_rgba(0,0,0,0.05)]",
65
+ children: /* @__PURE__ */ jsx("div", { className: "px-1.5 py-1 text-[15px] leading-6 text-muted-foreground/60", children: SPLASH_PLACEHOLDER })
64
66
  }
65
67
  );
66
68
  }
@@ -14,9 +14,12 @@ interface PinnedAgentsSectionProps {
14
14
  * with the rest of the app, and shares the agent registry with the dropdown
15
15
  * (no parallel fetches).
16
16
  *
17
- * The catalogue is asked for after the page is idle (see the effect below),
18
- * so the section is absent at first paint and appears once the list lands —
19
- * or sooner, when the person opens any agent picker.
17
+ * PINS READ ONLY THE PINS, AND ONLY ON SCREEN (2026-10-09). The section asks
18
+ * the catalog for the caller's starred rows (`ensureFavoriteRows`: `ues_list`,
19
+ * then `agx_get_list_full` filtered to those ids) once the section is actually
20
+ * visible (`useInView`) — never the whole catalogue, and never from a closed
21
+ * mobile sheet or collapsed rail. The full list loads only when a picker opens
22
+ * or the person searches agents; when it has, its rows answer here too.
20
23
  */
21
24
  export declare function PinnedAgentsSection({ activeAgentId, }: PinnedAgentsSectionProps): import("react").JSX.Element | null;
22
25
  export {};
@@ -5,18 +5,20 @@ import { useRouter, Link } from "../../../host/navigation.js";
5
5
  import { cn } from "@ai-matrx/design-system";
6
6
  import {
7
7
  FavoriteAgentButton,
8
+ useAgentCatalog,
8
9
  useAgentCatalogRows,
10
+ useAgentCatalogState,
9
11
  useAgentConsumer
10
12
  } from "@ai-matrx/agents/catalog/react";
11
13
  import { makeSelectFilteredAgents } from "@ai-matrx/agents/catalog";
12
14
  import { useAppStore } from "../../../store/hooks.js";
13
15
  import { stageChatAgentSwitch } from "./begin-fresh-chat.js";
14
- import { Button, DisclosureHeader } from "@ai-matrx/design-system/controls";
15
- import { ensureAgentCatalog } from "../../identity/agent-identity.js";
16
- import { whenPageIdle } from "@ai-matrx/kit/idle-scheduler";
16
+ import { Button, DisclosureHeader, RegionSkeleton } from "@ai-matrx/design-system/controls";
17
+ import { useInView } from "@ai-matrx/kit/hooks";
17
18
  const CONSUMER_ID = "chat-sidebar-pinned";
18
19
  const PINNED_COLLAPSED_LIMIT = 5;
19
20
  const selectPinnedAgents = makeSelectFilteredAgents();
21
+ const selectFavoriteRowsStatus = (state) => state.favoriteRowsStatus;
20
22
  function pinnedAgentHref(agentId) {
21
23
  return `/chat/a/${encodeURIComponent(agentId)}`;
22
24
  }
@@ -26,13 +28,13 @@ function PinnedAgentsSection({
26
28
  const router = useRouter();
27
29
  const store = useAppStore();
28
30
  const consumer = useAgentConsumer(CONSUMER_ID);
31
+ const catalog = useAgentCatalog();
32
+ const favoriteRowsStatus = useAgentCatalogState(selectFavoriteRowsStatus);
33
+ const [visibleRef, visible] = useInView({ rootMargin: "64px" });
29
34
  useEffect(() => {
30
- const controller = new AbortController();
31
- void whenPageIdle(controller.signal).then((idle) => {
32
- if (idle) void ensureAgentCatalog();
33
- });
34
- return () => controller.abort();
35
- }, []);
35
+ if (!visible) return;
36
+ void catalog.ensureFavoriteRows().catch(() => void 0);
37
+ }, [catalog, visible]);
36
38
  useEffect(() => {
37
39
  if (consumer.favFilter !== "yes") consumer.setFavFilter("yes");
38
40
  }, [consumer]);
@@ -42,7 +44,26 @@ function PinnedAgentsSection({
42
44
  const [showAll, setShowAll] = useState(false);
43
45
  const hasMorePins = pinned.length > PINNED_COLLAPSED_LIMIT;
44
46
  const visiblePins = showAll || !hasMorePins ? pinned : pinned.slice(0, PINNED_COLLAPSED_LIMIT);
45
- if (pinned.length === 0) return null;
47
+ if (pinned.length === 0) {
48
+ if (!visible) {
49
+ return /* @__PURE__ */ jsx("div", { ref: visibleRef, "aria-hidden": "true" });
50
+ }
51
+ if (favoriteRowsStatus === "idle" || favoriteRowsStatus === "loading") {
52
+ return /* @__PURE__ */ jsx("div", { className: "shrink-0 border-b border-border px-2 py-1.5", children: /* @__PURE__ */ jsx(RegionSkeleton, { shape: "rows", count: 2, "aria-label": "Loading pinned agents" }) });
53
+ }
54
+ if (favoriteRowsStatus === "failed") {
55
+ return /* @__PURE__ */ jsx("div", { className: "shrink-0 border-b border-border px-2 py-1.5", children: /* @__PURE__ */ jsx(
56
+ Button,
57
+ {
58
+ variant: "quiet",
59
+ className: "w-full",
60
+ onClick: () => void catalog.ensureFavoriteRows().catch(() => void 0),
61
+ children: "Pinned agents did not load. Retry"
62
+ }
63
+ ) });
64
+ }
65
+ return null;
66
+ }
46
67
  return /* @__PURE__ */ jsxs("div", { className: "shrink-0 border-b border-border", children: [
47
68
  /* @__PURE__ */ jsx(
48
69
  DisclosureHeader,
@@ -44,5 +44,5 @@ interface SmartAgentInputProps {
44
44
  */
45
45
  composer: ComposerPresentation;
46
46
  }
47
- export declare function SmartAgentInput({ conversationId, uploadRoot, uploadPath, enablePasteImages, compact, showSendButton, showVariableIcon, surfaceKey, draftAlias, disableSend, variablesPanelStyle, contextRailPresentation, contextRailAttachedItems, extraRightControls, surfaceValueAnchors, composer, }: SmartAgentInputProps): React.JSX.Element;
47
+ export declare function SmartAgentInput({ conversationId, uploadRoot, uploadPath, enablePasteImages, compact, showSendButton, showVariableIcon, surfaceKey, draftAlias, disableSend, variablesPanelStyle, contextRailPresentation, contextRailAttachedItems, extraRightControls, surfaceValueAnchors, composer, }: SmartAgentInputProps): React.JSX.Element | null;
48
48
  export {};
@@ -5,6 +5,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks.js";
5
5
  import { setVariablesPanelStyle } from "../../../redux/execution-system/instance-ui-state/instance-ui-state.slice.js";
6
6
  import { SmartAgentInputStacked } from "./SmartAgentInputStacked.js";
7
7
  import { InboxQueueStrip } from "./InboxQueueStrip.js";
8
+ import { selectMessagesUnreadableReason } from "../../../redux/execution-system/messages/messages.selectors.js";
8
9
  import { selectViewerCanReply } from "../../../redux/execution-system/conversations/conversations.selectors.js";
9
10
  import { ViewOnlyComposerBar } from "./ViewOnlyComposerBar.js";
10
11
  import { selectHasUserInput } from "../../../redux/execution-system/instance-user-input/instance-user-input.selectors.js";
@@ -45,11 +46,19 @@ function SmartAgentInput({
45
46
  );
46
47
  const heldSentence = useConversationHeld(conversationId);
47
48
  const sendDisabled = disableSend || ambientHasNothingToSend || heldSentence !== null;
49
+ const unreadableReason = useAppSelector((state) => {
50
+ if (!conversationId) return null;
51
+ const reason = selectMessagesUnreadableReason(conversationId)(state);
52
+ return reason === "private" || reason === "removed" ? reason : null;
53
+ });
48
54
  const viewerCanReply = useAppSelector(
49
55
  (state) => conversationId ? selectViewerCanReply(conversationId)(state) : true
50
56
  );
51
57
  const variableIconShown = showVariableIcon && variablesPanelStyle !== "hidden";
52
58
  const queueStrip = conversationId && !isLauncher ? /* @__PURE__ */ jsx(InboxQueueStrip, { conversationId }) : null;
59
+ if (unreadableReason) {
60
+ return null;
61
+ }
53
62
  if (conversationId && !viewerCanReply) {
54
63
  return /* @__PURE__ */ jsx(ViewOnlyComposerBar, {});
55
64
  }
@@ -110,8 +110,7 @@ function ComposerQuickActions({
110
110
  observer.disconnect();
111
111
  };
112
112
  }, [actions.length]);
113
- if (!organizationId) return null;
114
- if (knob === void 0) {
113
+ if (!organizationId || knob === void 0) {
115
114
  return /* @__PURE__ */ jsx(ComposerQuickActionsSkeleton, { className });
116
115
  }
117
116
  if (actions.length === 0) return null;
@@ -12,7 +12,6 @@ import {
12
12
  } from "../../../redux/execution-system/instance-variable-values/instance-variable-values.selectors.js";
13
13
  import { setUserVariableValue } from "../../../redux/execution-system/instance-variable-values/instance-variable-values.slice.js";
14
14
  import { selectShowVariablePanel } from "../../../redux/execution-system/instance-ui-state/instance-ui-state.selectors.js";
15
- import { toggleVariablePanel } from "../../../redux/execution-system/instance-ui-state/instance-ui-state.slice.js";
16
15
  import { Label } from "@ai-matrx/design-system";
17
16
  import { Input, DisclosureHeader } from "@ai-matrx/design-system/controls";
18
17
  import { Switch } from "../../../../ui/switch.js";
@@ -46,6 +45,7 @@ function AgentVariableForm({ conversationId }) {
46
45
  );
47
46
  const userValues = useAppSelector(selectUserVariableValues(conversationId));
48
47
  const showVariables = useAppSelector(selectShowVariablePanel(conversationId));
48
+ const [open, setOpen] = useState(true);
49
49
  if (!definitions || definitions.length === 0) return null;
50
50
  if (!showVariables) return null;
51
51
  const filledCount = definitions.filter((def) => {
@@ -59,14 +59,14 @@ function AgentVariableForm({ conversationId }) {
59
59
  /* @__PURE__ */ jsx(
60
60
  DisclosureHeader,
61
61
  {
62
- open: showVariables,
63
- onClick: () => dispatch(toggleVariablePanel(conversationId)),
62
+ open,
63
+ onClick: () => setOpen((v) => !v),
64
64
  chevron: "end",
65
- title: showVariables ? "Fill in the details below" : `${filledCount}/${definitions.length} details provided`,
65
+ title: open ? "Fill in the details below" : `${filledCount}/${definitions.length} details provided`,
66
66
  className: "shrink-0"
67
67
  }
68
68
  ),
69
- showVariables && /* @__PURE__ */ jsxs("div", { className: "border-t border-border flex-1 min-h-0 overflow-y-auto", children: [
69
+ open && /* @__PURE__ */ jsxs("div", { className: "border-t border-border flex-1 min-h-0 overflow-y-auto", children: [
70
70
  /* @__PURE__ */ jsx(BoundVariableChips, { conversationId }),
71
71
  /* @__PURE__ */ jsx("div", { className: "px-2.5 py-2", children: visibleDefs.map((def, i) => /* @__PURE__ */ jsxs("div", { children: [
72
72
  i > 0 && /* @__PURE__ */ jsx("div", { className: "border-t border-border my-2" }),
@@ -8,6 +8,7 @@ import { StaleDataNotice } from "../../../host/ui-slots.js";
8
8
  import {
9
9
  selectConversationMessages,
10
10
  selectMessagesHydrationFailure,
11
+ selectMessagesUnreadableReason,
11
12
  selectVisibleMessageGroupLimit
12
13
  } from "../../redux/execution-system/messages/messages.selectors.js";
13
14
  import {
@@ -32,7 +33,8 @@ import { AgentAssistantMessage } from "./assistant/AgentAssistantMessage.js";
32
33
  import { AgentEmptyMessageDisplay } from "./assistant/AgentEmptyMessageDisplay.js";
33
34
  import { ErrorBoundaryWithCapture } from "../../../host/ui-slots.js";
34
35
  import { ExampleTurnsGroup } from "../../message-flags/ExampleTurnsGroup.js";
35
- import { Pin } from "lucide-react";
36
+ import { Lock, Pin, Trash2 } from "lucide-react";
37
+ import { EmptyState } from "@ai-matrx/design-system/controls";
36
38
  import {
37
39
  hydratePinnedMessages,
38
40
  usePendingPinMessageIds,
@@ -63,6 +65,10 @@ function AgentConversationDisplay({
63
65
  const hydrationFailure = useAppSelector(
64
66
  selectMessagesHydrationFailure(conversationId)
65
67
  );
68
+ const unreadableReason = useAppSelector((state) => {
69
+ const reason = selectMessagesUnreadableReason(conversationId)(state);
70
+ return reason === "private" || reason === "removed" ? reason : null;
71
+ });
66
72
  const phase = useAppSelector(selectStreamPhase(conversationId));
67
73
  const latestRequestId = useAppSelector(selectLatestRequestId(conversationId));
68
74
  const visibleGroupLimit = useAppSelector(
@@ -206,6 +212,22 @@ function AgentConversationDisplay({
206
212
  ]);
207
213
  const resolveMenuContext = (target) => resolveMarkdownContext(target, conversationId);
208
214
  if (displayGroups.length === 0) {
215
+ if (unreadableReason) {
216
+ return /* @__PURE__ */ jsx("div", { className: "p-4", children: unreadableReason === "private" ? /* @__PURE__ */ jsx(
217
+ EmptyState,
218
+ {
219
+ icon: /* @__PURE__ */ jsx(Lock, { className: "h-5 w-5" }),
220
+ title: "Private chat",
221
+ line: "Only its owner can open it"
222
+ }
223
+ ) : /* @__PURE__ */ jsx(
224
+ EmptyState,
225
+ {
226
+ icon: /* @__PURE__ */ jsx(Trash2, { className: "h-5 w-5" }),
227
+ title: "Chat deleted"
228
+ }
229
+ ) });
230
+ }
209
231
  if (hydrationFailure) {
210
232
  return /* @__PURE__ */ jsx("div", { className: "p-4", children: /* @__PURE__ */ jsx(
211
233
  StaleDataNotice,
@@ -232,17 +232,7 @@ function AgentConversationColumn({
232
232
  ),
233
233
  children: [
234
234
  /* @__PURE__ */ jsxs("div", { className: "relative flex-1 min-h-0", children: [
235
- showLanding ? /* @__PURE__ */ jsx(
236
- motion.div,
237
- {
238
- className: "absolute inset-0 overflow-y-auto",
239
- initial: { opacity: 0 },
240
- animate: { opacity: 1 },
241
- transition: { duration: 0.2 },
242
- children: landingContent
243
- },
244
- "landing"
245
- ) : /* @__PURE__ */ jsxs(
235
+ showLanding ? /* @__PURE__ */ jsx("div", { className: "absolute inset-0 overflow-y-auto", children: landingContent }, "landing") : /* @__PURE__ */ jsxs(
246
236
  motion.div,
247
237
  {
248
238
  ref: scrollRef,
@@ -8,7 +8,7 @@
8
8
  * python-generated discriminated union).
9
9
  */
10
10
  import type { ChatRootState } from "../../../../store/root-state.js";
11
- import { type MessageRecord } from "./messages.slice.js";
11
+ import { type ConversationUnreadableReason, type MessageRecord } from "./messages.slice.js";
12
12
  import type { ContentSegment } from "../active-requests/active-requests.selectors.js";
13
13
  import { type MessagePart, type RenderBlockPayload } from "@ai-matrx/agents/generated/stream-events";
14
14
  import type { ApiEndpointMode } from "../../../types/instance.types.js";
@@ -69,6 +69,8 @@ export declare const selectHasMessages: (conversationId: string) => (state: Chat
69
69
  * including a genuinely empty conversation.
70
70
  */
71
71
  export declare const selectMessagesHydrationFailure: (conversationId: string) => (state: ChatRootState) => string | null;
72
+ /** `private` / `removed` when the conversation is not this person's to read. */
73
+ export declare const selectMessagesUnreadableReason: (conversationId: string) => (state: ChatRootState) => ConversationUnreadableReason | null;
72
74
  export declare const selectMessageContent: (conversationId: string, messageId: string) => (state: ChatRootState) => MessageRecord["content"] | undefined;
73
75
  export declare const selectMessageStatus: (conversationId: string, messageId: string) => (state: ChatRootState) => MessageRecord["status"] | undefined;
74
76
  export declare const selectMessageClientStatus: (conversationId: string, messageId: string) => (state: ChatRootState) => MessageRecord["_clientStatus"] | undefined;
@@ -1,5 +1,7 @@
1
1
  import { createSelector } from "@reduxjs/toolkit";
2
- import { nextTranscriptPosition } from "./messages.slice.js";
2
+ import {
3
+ nextTranscriptPosition
4
+ } from "./messages.slice.js";
3
5
  import { normalizeContentBlocks } from "../utils/normalize-content-blocks.js";
4
6
  import { isAttachmentMessagePart } from "../../../components/context-items/normalize.js";
5
7
  import {
@@ -58,6 +60,7 @@ const selectMessageCount = (conversationId) => (state) => state.messages.byConve
58
60
  const selectNextMessagePosition = (conversationId) => (state) => nextTranscriptPosition(state.messages.byConversationId[conversationId]);
59
61
  const selectHasMessages = (conversationId) => (state) => (state.messages.byConversationId[conversationId]?.orderedIds?.length ?? 0) > 0;
60
62
  const selectMessagesHydrationFailure = (conversationId) => (state) => state.messages.byConversationId[conversationId]?.hydrationFailure ?? null;
63
+ const selectMessagesUnreadableReason = (conversationId) => (state) => state.messages.byConversationId[conversationId]?.unreadableReason ?? null;
61
64
  const selectMessageContent = (conversationId, messageId) => (state) => state.messages.byConversationId[conversationId]?.byId?.[messageId]?.content;
62
65
  const selectMessageStatus = (conversationId, messageId) => (state) => state.messages.byConversationId[conversationId]?.byId?.[messageId]?.status;
63
66
  const selectMessageClientStatus = (conversationId, messageId) => (state) => state.messages.byConversationId[conversationId]?.byId?.[messageId]?._clientStatus;
@@ -626,6 +629,7 @@ export {
626
629
  selectMessageStreamRequestId,
627
630
  selectMessagesHydrationFailure,
628
631
  selectMessagesInterleavedRuns,
632
+ selectMessagesUnreadableReason,
629
633
  selectNextMessagePosition,
630
634
  selectOldestLoadedPosition,
631
635
  selectOrderedMessageIds,
@@ -252,7 +252,21 @@ export interface MessagesEntry {
252
252
  * conversation leaves it `null`. Cleared by any successful `hydrateMessages`.
253
253
  */
254
254
  hydrationFailure: string | null;
255
+ /**
256
+ * Why a reopen read came back empty, when that is not a failure:
257
+ * - `missing`: the server never saved it (a chat whose first turn was never
258
+ * sent). It opens as an empty new chat; callers that wait for a row to
259
+ * appear read this instead of a failure.
260
+ * - `private`: it exists and belongs to someone else.
261
+ * - `removed`: it was deleted.
262
+ * A retry can never change `private`/`removed`, so the transcript says what
263
+ * is true instead of offering "Try again" and the composer is absent. Set by
264
+ * `loadConversation`; cleared by any successful `hydrateMessages`.
265
+ */
266
+ unreadableReason: ConversationUnreadableReason | null;
255
267
  }
268
+ /** Why a held conversation id cannot be read by this person. */
269
+ export type ConversationUnreadableReason = "missing" | "private" | "removed";
256
270
  export interface MessagesState {
257
271
  byConversationId: Record<string, MessagesEntry>;
258
272
  }
@@ -359,6 +373,9 @@ export declare const initInstanceMessages: import("@reduxjs/toolkit").ActionCrea
359
373
  }, "messages/setConversationLabel">, setMessagesHydrationFailure: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
360
374
  conversationId: string;
361
375
  failure: string | null;
362
- }, "messages/setMessagesHydrationFailure">, clearMessages: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "messages/clearMessages">;
376
+ }, "messages/setMessagesHydrationFailure">, setMessagesUnreadable: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
377
+ conversationId: string;
378
+ reason: ConversationUnreadableReason | null;
379
+ }, "messages/setMessagesUnreadable">, clearMessages: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "messages/clearMessages">;
363
380
  declare const _default: import("redux").Reducer<MessagesState>;
364
381
  export default _default;
@@ -81,7 +81,8 @@ function getOrCreate(state, conversationId, apiEndpointMode = "agent") {
81
81
  hasMoreOlder: false,
82
82
  isLoadingOlder: false,
83
83
  visibleGroupLimit: null,
84
- hydrationFailure: null
84
+ hydrationFailure: null,
85
+ unreadableReason: null
85
86
  };
86
87
  state.byConversationId[conversationId] = entry;
87
88
  }
@@ -379,6 +380,7 @@ const messagesSlice = createSlice({
379
380
  }
380
381
  entry.isLoadingOlder = false;
381
382
  entry.hydrationFailure = null;
383
+ entry.unreadableReason = null;
382
384
  },
383
385
  /**
384
386
  * Prepend an older page of messages to the transcript. **Strictly
@@ -487,6 +489,13 @@ const messagesSlice = createSlice({
487
489
  const entry = getOrCreate(state, conversationId);
488
490
  entry.hydrationFailure = failure;
489
491
  },
492
+ /** Record that this conversation is not this person's to read. */
493
+ setMessagesUnreadable(state, action) {
494
+ const { conversationId, reason } = action.payload;
495
+ const entry = getOrCreate(state, conversationId);
496
+ entry.unreadableReason = reason;
497
+ if (reason) entry.hydrationFailure = null;
498
+ },
490
499
  /** Clear the transcript (e.g. auto-clear on a new run). */
491
500
  clearMessages(state, action) {
492
501
  const entry = state.byConversationId[action.payload];
@@ -544,6 +553,7 @@ const {
544
553
  removeMessage,
545
554
  setConversationLabel,
546
555
  setMessagesHydrationFailure,
556
+ setMessagesUnreadable,
547
557
  clearMessages
548
558
  } = messagesSlice.actions;
549
559
  var messages_slice_default = messagesSlice.reducer;
@@ -563,6 +573,7 @@ export {
563
573
  sentWithRequest,
564
574
  setConversationLabel,
565
575
  setMessagesHydrationFailure,
576
+ setMessagesUnreadable,
566
577
  setOlderLoading,
567
578
  setVisibleGroupLimit,
568
579
  shouldCreateOptimisticUserMessage,
@@ -81,6 +81,18 @@ export interface FetchBundleOptions {
81
81
  }
82
82
  declare function describeSupabaseError(err: unknown): Record<string, unknown>;
83
83
  export { describeSupabaseError };
84
+ /**
85
+ * Why a conversation id this person holds read back empty, asked only after
86
+ * the bundle RPC answered NULL:
87
+ * - `missing` — the server never saved it (an id minted in a browser whose
88
+ * first turn was never sent). Nothing to load and nothing wrong.
89
+ * - `removed` — it was deleted.
90
+ * - `private` — it exists and belongs to someone else (AI chat is Private).
91
+ * `null` when the question itself could not be answered — the caller then
92
+ * keeps the honest "couldn't load" state.
93
+ */
94
+ export type ConversationEmptyReadReason = "missing" | "removed" | "private";
95
+ export declare function fetchConversationUnreadableReason(conversationId: string): Promise<ConversationEmptyReadReason | null>;
84
96
  /**
85
97
  * Fetches a page of conversation history via `get_cx_conversation_bundle`.
86
98
  * Falls back to direct table queries when the RPC is unavailable (dev DBs
@@ -19,6 +19,27 @@ function describeSupabaseError(err) {
19
19
  }
20
20
  return { raw: err };
21
21
  }
22
+ async function fetchConversationUnreadableReason(conversationId) {
23
+ try {
24
+ const { data, error } = await supabase.schema("chat").rpc("conversation_unreadable_reason", {
25
+ p_conversation_id: conversationId
26
+ });
27
+ if (error) {
28
+ console.error(
29
+ "[conversation-bundle] conversation_unreadable_reason failed:",
30
+ describeSupabaseError(error)
31
+ );
32
+ return null;
33
+ }
34
+ return data === "missing" || data === "removed" || data === "private" ? data : null;
35
+ } catch (err) {
36
+ console.error(
37
+ "[conversation-bundle] conversation_unreadable_reason threw:",
38
+ describeSupabaseError(err)
39
+ );
40
+ return null;
41
+ }
42
+ }
22
43
  async function fetchConversationBundle(conversationId, options = {}) {
23
44
  const {
24
45
  messageLimit = 50,
@@ -295,6 +316,7 @@ export {
295
316
  describeSupabaseError,
296
317
  extractBundleToolCalls,
297
318
  fetchConversationBundle,
319
+ fetchConversationUnreadableReason,
298
320
  messageRowToRecord,
299
321
  requestRowToRecord,
300
322
  toolCallRowToRecord,
@@ -13,7 +13,8 @@ import {
13
13
  } from "../conversations/conversations.slice.js";
14
14
  import {
15
15
  hydrateMessages,
16
- setMessagesHydrationFailure
16
+ setMessagesHydrationFailure,
17
+ setMessagesUnreadable
17
18
  } from "../messages/messages.slice.js";
18
19
  import { reconcileMessagesArtifacts } from "../../../../host/ui-slots.js";
19
20
  import {
@@ -62,6 +63,7 @@ import {
62
63
  import { loadCodeEditHistoryThunk } from "../../../../host/ui-slots.js";
63
64
  import {
64
65
  CONVERSATION_NOT_MATERIALIZED,
66
+ fetchConversationUnreadableReason,
65
67
  fetchConversationBundle,
66
68
  describeSupabaseError,
67
69
  extractBundleToolCalls,
@@ -118,12 +120,26 @@ const loadConversation = createAsyncThunk(
118
120
  if (err?.code === CONVERSATION_NOT_MATERIALIZED) {
119
121
  void historyPromise.catch?.(() => void 0);
120
122
  if (expectMaterialized) {
121
- dispatch(
122
- setMessagesHydrationFailure({
123
- conversationId,
124
- failure: NOT_READABLE_DETAIL
125
- })
126
- );
123
+ const reason = await fetchConversationUnreadableReason(conversationId);
124
+ if ((getState().messages.byConversationId[conversationId]?.orderedIds?.length ?? 0) > 0) {
125
+ return { conversationId };
126
+ }
127
+ if (reason) {
128
+ if (reason === "missing") {
129
+ console.info(
130
+ "[loadConversation] %s was never saved \u2014 opening it as a new chat",
131
+ conversationId
132
+ );
133
+ }
134
+ dispatch(setMessagesUnreadable({ conversationId, reason }));
135
+ } else {
136
+ dispatch(
137
+ setMessagesHydrationFailure({
138
+ conversationId,
139
+ failure: NOT_READABLE_DETAIL
140
+ })
141
+ );
142
+ }
127
143
  }
128
144
  return { conversationId };
129
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.7.23",
3
+ "version": "0.7.24",
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,16 +4681,17 @@
4681
4681
  "server-only": "^0.0.1",
4682
4682
  "uuid": "^14.0.2",
4683
4683
  "zod": "^4.6.5",
4684
+ "@ai-matrx/agents": "latest",
4685
+ "@ai-matrx/alchemy": "latest",
4684
4686
  "@ai-matrx/associations": "latest",
4685
4687
  "@ai-matrx/browser-audio": "latest",
4686
- "@ai-matrx/alchemy": "latest",
4687
4688
  "@ai-matrx/canvas": "latest",
4688
4689
  "@ai-matrx/content-ir": "latest",
4689
4690
  "@ai-matrx/content-ir-react": "latest",
4690
4691
  "@ai-matrx/data": "latest",
4691
4692
  "@ai-matrx/design-system": "latest",
4692
- "@ai-matrx/diff": "latest",
4693
4693
  "@ai-matrx/icons": "latest",
4694
+ "@ai-matrx/diff": "latest",
4694
4695
  "@ai-matrx/kit": "latest",
4695
4696
  "@ai-matrx/media": "latest",
4696
4697
  "@ai-matrx/print": "latest",
@@ -4698,8 +4699,7 @@
4698
4699
  "@ai-matrx/records": "latest",
4699
4700
  "@ai-matrx/records-ui": "latest",
4700
4701
  "@ai-matrx/rich-content": "latest",
4701
- "@ai-matrx/rich-editor": "latest",
4702
- "@ai-matrx/agents": "latest"
4702
+ "@ai-matrx/rich-editor": "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": "39aa4a6200282fa60c92b648157a3627a13b5083"
4821
+ "gitHead": "cab8d9bc66fa516a57684222b15e62677a35d4fa"
4822
4822
  }