@ai-matrx/chat 0.7.3 → 0.7.5

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 (37) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/agents/addressing/agentAddress.d.ts +8 -0
  3. package/dist/agents/addressing/agentAddress.js +1 -0
  4. package/dist/agents/addressing/useAgentHref.js +8 -1
  5. package/dist/agents/components/conversation-history/ConversationHistorySidebar.d.ts +7 -0
  6. package/dist/agents/components/conversation-history/ConversationHistorySidebar.js +13 -7
  7. package/dist/agents/components/shared/assistant-message-layout.d.ts +21 -0
  8. package/dist/agents/components/shared/assistant-message-layout.js +6 -0
  9. package/dist/agents/hooks/useCreatorOwnershipSync.js +3 -1
  10. package/dist/agents/redux/conversation-history/thunks.js +2 -1
  11. package/dist/agents/redux/conversation-list/conversation-row-actions.thunks.d.ts +5 -1
  12. package/dist/agents/redux/conversation-list/conversation-row-actions.thunks.js +64 -12
  13. package/dist/agents/redux/execution-system/instance-resources/rendered-artifact-draft.d.ts +23 -0
  14. package/dist/agents/redux/execution-system/instance-resources/rendered-artifact-draft.js +100 -0
  15. package/dist/agents/redux/execution-system/instance-user-input/composer-draft.middleware.js +9 -0
  16. package/dist/agents/redux/execution-system/instance-user-input/useComposerDraftRestore.js +3 -1
  17. package/dist/agents/redux/execution-system/message-crud/soft-delete-conversation.thunk.js +22 -12
  18. package/dist/agents/redux/execution-system/thunks/execute-instance.thunk.js +1 -1
  19. package/dist/agents/redux/execution-system/thunks/execute-manual-instance.thunk.js +1 -1
  20. package/dist/agents/redux/execution-system/thunks/frozen-submission.d.ts +5 -0
  21. package/dist/agents/redux/execution-system/thunks/frozen-submission.js +2 -1
  22. package/dist/agents/redux/execution-system/utils/content-prefilter.d.ts +50 -0
  23. package/dist/agents/redux/execution-system/utils/content-prefilter.js +243 -0
  24. package/dist/agents/redux/execution-system/utils/stream-block-accumulator.d.ts +335 -65
  25. package/dist/agents/redux/execution-system/utils/stream-block-accumulator.js +1909 -224
  26. package/dist/conversation/hooks/useDomCapturePrint.js +13 -2
  27. package/dist/conversation/utils/frame-print.js +20 -2
  28. package/dist/conversation/utils/isolate-for-capture.d.ts +19 -0
  29. package/dist/conversation/utils/isolate-for-capture.js +55 -0
  30. package/dist/host/admin-seat.d.ts +19 -0
  31. package/dist/host/admin-seat.js +8 -0
  32. package/dist/host/defaults/server-api.d.ts +69 -0
  33. package/dist/host/defaults/server-api.js +6 -0
  34. package/dist/host/server/call-api.d.ts +69 -0
  35. package/dist/host/server/call-api.js +8 -0
  36. package/dist/ui/markdown-stream/EnhancedChatMarkdown.js +5 -1
  37. package/package.json +12 -11
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.7.5
4
+
5
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
6
+ `git diff npm/chat/v0.7.4..npm/chat/v0.7.5 -- apps/shared/chat`).
7
+ No source changes intended and no consumer action required.
8
+
9
+ ## 0.7.4
10
+ ### Unreleased
11
+
12
+ - THE ADMIN SEAT beyond reply: new `adminSeatOpen(state)` (`host/admin-seat.ts`). On an admin page a platform admin is the
13
+ creator of every agent (`useCreatorOwnershipSync`), every agent link opens in the System Agents tree
14
+ (`AgentAddressViewer.adminSeat`), and an agent-scoped history read shows every person's runs. User pages unchanged.
15
+ - `ConversationHistorySidebar` (dense): new `titleFirst` prop for narrow hosts (a board tile's sidebar). Rows drop the agent
16
+ chip and origin badge and show the star only on a favorite (adding one stays in the row menu), so the title wins the row.
17
+ Default unchanged.
18
+
3
19
  ## 0.7.3
4
20
 
5
21
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -112,6 +112,14 @@ export declare function isSystemAgentType(agentType: string | null): boolean;
112
112
  */
113
113
  export interface AgentAddressViewer {
114
114
  isAdmin: boolean;
115
+ /**
116
+ * THE ADMIN SEAT (`adminSeatOpen`): an admin standing on an admin page. There
117
+ * EVERY agent — user agents included — opens in the admin System Agents
118
+ * tree, so editing someone's agent from the admin system stays inside it
119
+ * (the admin lane, and with it the admin's write access, is open only
120
+ * there). Off the admin pages the seat is closed and the kind rule applies.
121
+ */
122
+ adminSeat?: boolean;
115
123
  }
116
124
  /** The route prefix an agent of this kind lives under, for this viewer. */
117
125
  export declare function agentBasePathFor(agentType: string | null, viewer?: AgentAddressViewer): string;
@@ -8,6 +8,7 @@ function isSystemAgentType(agentType) {
8
8
  return agentType === "builtin";
9
9
  }
10
10
  function agentBasePathFor(agentType, viewer) {
11
+ if (viewer?.adminSeat && viewer.isAdmin) return SYSTEM_AGENT_BASE_PATH;
11
12
  return isSystemAgentType(agentType) && (viewer?.isAdmin ?? true) ? SYSTEM_AGENT_BASE_PATH : AGENT_BASE_PATH;
12
13
  }
13
14
  const ADMIN_ONLY_AGENT_SUBROUTES = /* @__PURE__ */ new Set(["samples"]);
@@ -13,6 +13,7 @@ import {
13
13
  resolveAgentAddress
14
14
  } from "./agentAddressCache.js";
15
15
  import { selectIsAdmin } from "../../host/identity.js";
16
+ import { adminSeatOpen } from "../../host/admin-seat.js";
16
17
  const noSubscribe = () => () => {
17
18
  };
18
19
  function useAgentAddressViewer() {
@@ -25,7 +26,13 @@ function useAgentAddressViewer() {
25
26
  read,
26
27
  read
27
28
  );
28
- return isAdmin === void 0 ? void 0 : { isAdmin };
29
+ const readSeat = () => store ? adminSeatOpen(store.getState()) : false;
30
+ const adminSeat = useSyncExternalStore(
31
+ store ? store.subscribe : noSubscribe,
32
+ readSeat,
33
+ () => false
34
+ );
35
+ return isAdmin === void 0 ? void 0 : { isAdmin, adminSeat };
29
36
  }
30
37
  function useAgentHref({
31
38
  id,
@@ -143,6 +143,13 @@ export interface ConversationHistorySidebarProps {
143
143
  * list refreshes each time it mounts.
144
144
  */
145
145
  keepLoaded?: boolean;
146
+ /**
147
+ * Rows in a narrow host (a board tile's sidebar): the title wins the row.
148
+ * The agent chip is dropped (every row would repeat the one agent) and the
149
+ * star shows only on a row that is already a favorite — adding one lives in
150
+ * the row menu. The time stays, compact. Default false.
151
+ */
152
+ titleFirst?: boolean;
146
153
  className?: string;
147
154
  /** Extra classes for the built-in search input (dense). */
148
155
  searchInputClassName?: string;
@@ -380,6 +380,7 @@ const DenseView = ({
380
380
  headerActions,
381
381
  showSearch = true,
382
382
  showGroupingToggle = true,
383
+ titleFirst = false,
383
384
  className,
384
385
  searchInputClassName,
385
386
  sectionHeaderClassName
@@ -510,7 +511,8 @@ const DenseView = ({
510
511
  onToggleFavorite: onToggleFavoriteResolved,
511
512
  resolveHref,
512
513
  surfaceKey,
513
- getSourceMenuCtx
514
+ getSourceMenuCtx,
515
+ titleFirst
514
516
  },
515
517
  `search-${conv.conversationId}`
516
518
  ))
@@ -536,7 +538,8 @@ const DenseView = ({
536
538
  onToggleFavorite: onToggleFavoriteResolved,
537
539
  resolveHref,
538
540
  surfaceKey,
539
- getSourceMenuCtx
541
+ getSourceMenuCtx,
542
+ titleFirst
540
543
  },
541
544
  `fav-${conv.conversationId}`
542
545
  ))
@@ -562,7 +565,8 @@ const DenseView = ({
562
565
  onToggleFavorite: onToggleFavoriteResolved,
563
566
  resolveHref,
564
567
  surfaceKey,
565
- getSourceMenuCtx
568
+ getSourceMenuCtx,
569
+ titleFirst
566
570
  },
567
571
  conv.conversationId
568
572
  ))
@@ -598,6 +602,7 @@ const DenseView = ({
598
602
  resolveHref,
599
603
  surfaceKey,
600
604
  getSourceMenuCtx,
605
+ titleFirst,
601
606
  showAgentHint: false
602
607
  },
603
608
  conv.conversationId
@@ -886,19 +891,20 @@ const Row = ({
886
891
  resolveHref,
887
892
  surfaceKey,
888
893
  getSourceMenuCtx,
889
- showAgentHint = true
894
+ showAgentHint = true,
895
+ titleFirst = false
890
896
  }) => {
891
897
  const title = conv.title?.trim() || untitled(conv);
892
898
  const agentName = useAppSelector(
893
899
  (s) => conv.agentId ? selectAgentById(s, conv.agentId)?.name ?? null : null
894
900
  );
895
- const star = onToggleFavorite ? /* @__PURE__ */ jsx(Button, { variant: "quiet", icon: isFavorite ? /* @__PURE__ */ jsx(Star, { fill: "currentColor" }) : /* @__PURE__ */ jsx(StarOff, {}), onClick: (e) => {
901
+ const star = onToggleFavorite && (!titleFirst || isFavorite) ? /* @__PURE__ */ jsx(Button, { variant: "quiet", icon: isFavorite ? /* @__PURE__ */ jsx(Star, { fill: "currentColor" }) : /* @__PURE__ */ jsx(StarOff, {}), onClick: (e) => {
896
902
  e.stopPropagation();
897
903
  e.preventDefault();
898
904
  onToggleFavorite(conv);
899
905
  }, "aria-label": isFavorite ? "Remove from favorites" : "Add to favorites", title: isFavorite ? "Remove from favorites" : "Add to favorites" }) : null;
900
906
  const meta = formatRelative(conv.updatedAt);
901
- const originBadge = conv.originClass && conv.originClass !== "human" ? /* @__PURE__ */ jsx(
907
+ const originBadge = !titleFirst && conv.originClass && conv.originClass !== "human" ? /* @__PURE__ */ jsx(
902
908
  "span",
903
909
  {
904
910
  className: "inline-flex h-4 shrink-0 items-center rounded-full border border-border px-1.5 text-[9px] font-medium text-muted-foreground",
@@ -906,7 +912,7 @@ const Row = ({
906
912
  children: originClassLabel(conv.originClass)
907
913
  }
908
914
  ) : null;
909
- const trailing = showAgentHint && conv.agentId ? /* @__PURE__ */ jsxs(Fragment, { children: [
915
+ const trailing = showAgentHint && !titleFirst && conv.agentId ? /* @__PURE__ */ jsxs(Fragment, { children: [
910
916
  originBadge,
911
917
  /* @__PURE__ */ jsx(
912
918
  "span",
@@ -27,3 +27,24 @@ export declare const ASSISTANT_MESSAGE_COLUMN_INSET_CLASS = "px-2";
27
27
  export declare const WIDE_FIGURE_MAX_WIDTH_PX = 1100;
28
28
  export declare const WIDE_FIGURE_SCOPE_ATTRIBUTE = "data-wide-figures";
29
29
  export declare const WIDE_FIGURE_CLASS = "[[data-wide-figures]_&]:w-[max(100%,min(1100px,100cqi_-_2rem))] [[data-wide-figures]_&]:ml-[calc((100%_-_max(100%,min(1100px,100cqi_-_2rem)))/2)]";
30
+ /**
31
+ * The marker a wide figure carries (beside `WIDE_FIGURE_CLASS`) so the message
32
+ * wrappers around it can make room for it — see `WIDE_FIGURE_HOST_CLASS`.
33
+ */
34
+ export declare const WIDE_FIGURE_ATTRIBUTE = "data-wide-figure";
35
+ /**
36
+ * A message's own wrappers clip sideways (`overflow-x: clip`) so wide prose
37
+ * never blows out the column — which also cut a wide figure back down to the
38
+ * column, hiding its left edge and its header actions (2026-10-08). A wrapper
39
+ * carrying this class, in an opted-in transcript, when it holds a wide figure,
40
+ * moves its clip edge out to the figure's band: negative margins + equal
41
+ * padding, so its CONTENT box stays exactly the column while its clip box is
42
+ * the band. Inert everywhere else (no scope, no figure → no change).
43
+ */
44
+ export declare const WIDE_FIGURE_HOST_CLASS = "[[data-wide-figures]_&:has([data-wide-figure])]:[--wf-bleed:max(0px,calc((min(1100px,100cqi_-_2rem)_-_100%)/2))] [[data-wide-figures]_&:has([data-wide-figure])]:w-auto [[data-wide-figures]_&:has([data-wide-figure])]:mx-[calc(-1*var(--wf-bleed))] [[data-wide-figures]_&:has([data-wide-figure])]:px-[var(--wf-bleed)]";
45
+ /**
46
+ * On the wrapper that holds the blocks: while it has been widened for a figure,
47
+ * every OTHER block keeps the column's clip edge, so prose still never spills
48
+ * into the band.
49
+ */
50
+ export declare const WIDE_FIGURE_PROSE_CLIP_CLASS = "[[data-wide-figures]_&:has([data-wide-figure])>*:not(:has([data-wide-figure]))]:overflow-x-clip";
@@ -3,10 +3,16 @@ const ASSISTANT_MESSAGE_COLUMN_INSET_CLASS = "px-2";
3
3
  const WIDE_FIGURE_MAX_WIDTH_PX = 1100;
4
4
  const WIDE_FIGURE_SCOPE_ATTRIBUTE = "data-wide-figures";
5
5
  const WIDE_FIGURE_CLASS = "[[data-wide-figures]_&]:w-[max(100%,min(1100px,100cqi_-_2rem))] [[data-wide-figures]_&]:ml-[calc((100%_-_max(100%,min(1100px,100cqi_-_2rem)))/2)]";
6
+ const WIDE_FIGURE_ATTRIBUTE = "data-wide-figure";
7
+ const WIDE_FIGURE_HOST_CLASS = "[[data-wide-figures]_&:has([data-wide-figure])]:[--wf-bleed:max(0px,calc((min(1100px,100cqi_-_2rem)_-_100%)/2))] [[data-wide-figures]_&:has([data-wide-figure])]:w-auto [[data-wide-figures]_&:has([data-wide-figure])]:mx-[calc(-1*var(--wf-bleed))] [[data-wide-figures]_&:has([data-wide-figure])]:px-[var(--wf-bleed)]";
8
+ const WIDE_FIGURE_PROSE_CLIP_CLASS = "[[data-wide-figures]_&:has([data-wide-figure])>*:not(:has([data-wide-figure]))]:overflow-x-clip";
6
9
  export {
7
10
  ASSISTANT_MESSAGE_COLUMN_CLASS,
8
11
  ASSISTANT_MESSAGE_COLUMN_INSET_CLASS,
12
+ WIDE_FIGURE_ATTRIBUTE,
9
13
  WIDE_FIGURE_CLASS,
14
+ WIDE_FIGURE_HOST_CLASS,
10
15
  WIDE_FIGURE_MAX_WIDTH_PX,
16
+ WIDE_FIGURE_PROSE_CLIP_CLASS,
11
17
  WIDE_FIGURE_SCOPE_ATTRIBUTE
12
18
  };
@@ -2,6 +2,7 @@
2
2
  import { useEffect } from "react";
3
3
  import { useAppDispatch, useAppSelector } from "../../store/hooks.js";
4
4
  import { setIsCreator } from "../../host/prefs.js";
5
+ import { adminSeatOpen } from "../../host/admin-seat.js";
5
6
  import { selectAgentIsOwner } from "../redux/agent-definition/selectors.js";
6
7
  import { fetchAgentAccessLevel } from "../redux/agent-definition/thunks.js";
7
8
  function useCreatorOwnershipSync(agentId) {
@@ -14,7 +15,8 @@ function useCreatorOwnershipSync(agentId) {
14
15
  void dispatch(fetchAgentAccessLevel(agentId));
15
16
  }
16
17
  }, [dispatch, agentId, isOwnerFlag]);
17
- const isOwner = !!agentId && isOwnerFlag === true;
18
+ const adminSeat = useAppSelector((state) => adminSeatOpen(state));
19
+ const isOwner = !!agentId && (isOwnerFlag === true || adminSeat);
18
20
  useEffect(() => {
19
21
  dispatch(setIsCreator(isOwner));
20
22
  return () => {
@@ -24,6 +24,7 @@ import {
24
24
  } from "./types.js";
25
25
  import { readListRpc } from "../../../host/ui-slots.js";
26
26
  import { getUserId, hasBrowserSession } from "../../../host/identity.js";
27
+ import { adminSeatOpen } from "../../../host/admin-seat.js";
27
28
  const fetchConversationHistory = createAsyncThunk(
28
29
  "conversationHistory/fetchPage",
29
30
  async (args, { dispatch, getState, rejectWithValue }) => {
@@ -66,7 +67,7 @@ const fetchConversationHistory = createAsyncThunk(
66
67
  return { scopeId: args.scopeId, items: [], hasMore: false, nextOffset: 0, replace };
67
68
  }
68
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);
69
- if (viewerId) {
70
+ if (viewerId && !(agentIds.length > 0 && adminSeatOpen(getState()))) {
70
71
  query = query.eq("created_by", viewerId);
71
72
  }
72
73
  if (agentIds.length > 0) {
@@ -7,7 +7,11 @@
7
7
  * 1. Snapshot the current value from the canonical entity store.
8
8
  * 2. Patch every slice that mirrors the row (list, history scopes, the
9
9
  * active `conversations`/`messages` instance if it's loaded).
10
- * 3. Hit Supabase directly — RLS scopes the update to the owner.
10
+ * 3. Hit Supabase directly — RLS scopes the update to the owner. On THE ADMIN
11
+ * SEAT (an /administration page, host grants admin) the write goes to the
12
+ * server door instead (`PATCH /cx/conversations/{id}`), which admits the
13
+ * verified admin through the admin lane: `chat.conversation` has only
14
+ * `platform_admin_read`, by design, so a direct write there is refused.
11
15
  * 4. On failure, revert to the snapshot in the same slices.
12
16
  *
13
17
  * No new reducers are needed: the existing generic `patchConversation` and
@@ -20,6 +20,17 @@ import {
20
20
  } from "../execution-system/conversations/conversations.slice.js";
21
21
  import { selectConversationIsEphemeral } from "../execution-system/conversations/conversations.selectors.js";
22
22
  import { forkConversationServer } from "../execution-system/message-crud/server/fork-conversation-server.thunk.js";
23
+ import { adminSeatOpen } from "../../../host/admin-seat.js";
24
+ import {
25
+ callConversationSandboxBind,
26
+ callConversationSandboxUnbind,
27
+ callConversationUpdate
28
+ } from "../../../host/server/call-api.js";
29
+ async function writeSettingsThroughServer(dispatch, conversationId, body, action) {
30
+ const result = await dispatch(callConversationUpdate({ conversationId, body }));
31
+ if (result.error) return refusalWords(result.error, action);
32
+ return null;
33
+ }
23
34
  function refusalWords(err, action) {
24
35
  const w = describeWriteFailure(err, { action, remedy: "Try again." });
25
36
  return `${w.title} ${w.description}`;
@@ -61,10 +72,12 @@ const renameConversation = createAsyncThunk(
61
72
  title: nextTitle
62
73
  })
63
74
  );
64
- const { error } = await tryWriteOne(
75
+ const error = adminSeatOpen(getState()) ? await writeSettingsThroughServer(dispatch, conversationId, { title: nextTitle }, "rename this conversation").then(
76
+ (message) => message ? { message } : null
77
+ ) : (await tryWriteOne(
65
78
  supabase.schema("chat").from("conversation").update({ title: nextTitle, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", conversationId).select("id"),
66
79
  { action: "rename", noun: "conversation" }
67
- );
80
+ )).error;
68
81
  if (error) {
69
82
  dispatch(revertRename({ conversationId, previousTitle }));
70
83
  dispatch(
@@ -110,9 +123,14 @@ const setConversationArchived = createAsyncThunk(
110
123
  const before = findConversationRow(getState(), conversationId);
111
124
  const nextStatus = archived ? "archived" : "active";
112
125
  const action = archived ? "archive this conversation" : "restore this conversation";
113
- const { data, error } = await supabase.schema("chat").from("conversation").update({ status: nextStatus, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", conversationId).select("id");
114
- const refusal = error ?? landedOrRefusal(data, `chat.conversation status=${nextStatus} ${conversationId}`);
115
- if (refusal) return rejectWithValue({ message: refusalWords(refusal, action) });
126
+ if (adminSeatOpen(getState())) {
127
+ const refused = await writeSettingsThroughServer(dispatch, conversationId, { status: nextStatus }, action);
128
+ if (refused) return rejectWithValue({ message: refused });
129
+ } else {
130
+ const { data, error } = await supabase.schema("chat").from("conversation").update({ status: nextStatus, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", conversationId).select("id");
131
+ const refusal = error ?? landedOrRefusal(data, `chat.conversation status=${nextStatus} ${conversationId}`);
132
+ if (refusal) return rejectWithValue({ message: refusalWords(refusal, action) });
133
+ }
116
134
  dispatch(
117
135
  patchConversation({ conversationId, patch: { status: nextStatus } })
118
136
  );
@@ -135,19 +153,27 @@ const setConversationArchived = createAsyncThunk(
135
153
  );
136
154
  const setConversationExcludeFromKg = createAsyncThunk(
137
155
  "conversationRow/setExcludeFromKg",
138
- async ({ conversationId, excludeFromKg }, { dispatch, rejectWithValue }) => {
156
+ async ({ conversationId, excludeFromKg }, { dispatch, getState, rejectWithValue }) => {
157
+ const kgAction = excludeFromKg ? "exclude this conversation from the knowledge graph" : "include this conversation in the knowledge graph";
158
+ if (adminSeatOpen(getState())) {
159
+ const refused = await writeSettingsThroughServer(
160
+ dispatch,
161
+ conversationId,
162
+ { exclude_from_kg: excludeFromKg },
163
+ kgAction
164
+ );
165
+ if (refused) return rejectWithValue({ message: refused });
166
+ dispatch(patchConversation({ conversationId, patch: { excludeFromKg } }));
167
+ dispatch(patchConversationInScopes({ conversationId, patch: { excludeFromKg } }));
168
+ return { conversationId, excludeFromKg };
169
+ }
139
170
  const { data, error } = await supabase.schema("chat").from("conversation").update({
140
171
  exclude_from_kg: excludeFromKg,
141
172
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
142
173
  }).eq("id", conversationId).select("id");
143
174
  const refusal = error ?? landedOrRefusal(data, `chat.conversation exclude_from_kg=${excludeFromKg} ${conversationId}`);
144
175
  if (refusal) {
145
- return rejectWithValue({
146
- message: refusalWords(
147
- refusal,
148
- excludeFromKg ? "exclude this conversation from the knowledge graph" : "include this conversation in the knowledge graph"
149
- )
150
- });
176
+ return rejectWithValue({ message: refusalWords(refusal, kgAction) });
151
177
  }
152
178
  dispatch(patchConversation({ conversationId, patch: { excludeFromKg } }));
153
179
  dispatch(
@@ -199,6 +225,32 @@ const setConversationSandbox = createAsyncThunk(
199
225
  if (isEphemeral || (record?.cacheOnly ?? true)) {
200
226
  return { conversationId, persisted: false };
201
227
  }
228
+ if (adminSeatOpen(state)) {
229
+ const revert = () => dispatch(
230
+ patchInstanceConversation({
231
+ conversationId,
232
+ sandboxBinding: previous,
233
+ sandboxBindingPersisted: previousPersisted
234
+ })
235
+ );
236
+ if (ref?.kind === "local-pc") {
237
+ revert();
238
+ return rejectWithValue({
239
+ message: "A local computer can't be bound from the admin section. Open the conversation as its owner to bind it."
240
+ });
241
+ }
242
+ const result = ref ? await dispatch(
243
+ callConversationSandboxBind({ conversationId, body: { sandbox_instance_id: ref.rowId } })
244
+ ) : await dispatch(callConversationSandboxUnbind({ conversationId }));
245
+ if (result.error) {
246
+ revert();
247
+ return rejectWithValue({
248
+ message: refusalWords(result.error, ref ? "bind this sandbox" : "unbind this sandbox")
249
+ });
250
+ }
251
+ dispatch(patchInstanceConversation({ conversationId, sandboxBindingPersisted: true }));
252
+ return { conversationId, persisted: true };
253
+ }
202
254
  const { data: row, error: readError } = await supabase.schema("chat").from("conversation").select("metadata").eq("id", conversationId).single();
203
255
  if (readError) {
204
256
  dispatch(
@@ -0,0 +1,23 @@
1
+ /**
2
+ * THE COMPOSER'S ATTACHED CODE / TEXT CHIPS SURVIVE A RELOAD.
3
+ *
4
+ * A picked file outlives a reload because attaching it writes a durable edge on
5
+ * the conversation; an "Attach code" / "Attach text" chip (a `rendered_artifact`
6
+ * reference to a canvas item or html page) lived only in Redux, so a reload
7
+ * took it away while the files beside it came back (2026-10-08). This keeps
8
+ * those chips with the composer's text draft — same storage, same scope (this
9
+ * browser, this signed-in person, `composer-draft-store.ts`), written by the
10
+ * composer draft middleware whenever the conversation's resources change and
11
+ * offered back by `useComposerDraftRestore`. A chip that was sent is gone from
12
+ * the record the moment its turn is submitted.
13
+ */
14
+ import type { Dispatch } from "@reduxjs/toolkit";
15
+ import type { ChatRootState } from "../../../../store/root-state.js";
16
+ /** The conversation a resource action is about, or null for any other action. */
17
+ export declare function renderedArtifactDraftConversation(action: unknown): string | null;
18
+ /** Write (or clear) the conversation's unsent code/text chips from the current state. */
19
+ export declare function persistRenderedArtifactChips(state: ChatRootState, conversationId: string, owner: string | null): void;
20
+ /** Put back this person's unsent code/text chips for a conversation; answers how many came back. */
21
+ export declare function restoreRenderedArtifactChips(dispatch: Dispatch, getState: () => ChatRootState, conversationId: string, owner: string | null): number;
22
+ /** Test seam ONLY. */
23
+ export declare function __resetRenderedArtifactDraftForTest(): void;
@@ -0,0 +1,100 @@
1
+ import { RENDERED_ARTIFACT_BLOCK_TYPE } from "../../../utils/renderedArtifactContext.js";
2
+ import {
3
+ addResource,
4
+ clearAllResources,
5
+ clearSubmittedResources,
6
+ markResourcesSubmitted,
7
+ removeInstanceResources,
8
+ removeResource,
9
+ setResourcePreview,
10
+ updateResourceOptions
11
+ } from "./instance-resources.slice.js";
12
+ const KEY_PREFIX = "matrx:composer-artifact-chips:";
13
+ const TTL_MS = 7 * 24 * 60 * 60 * 1e3;
14
+ const RESOURCE_ACTIONS = /* @__PURE__ */ new Set([
15
+ addResource.type,
16
+ removeResource.type,
17
+ setResourcePreview.type,
18
+ updateResourceOptions.type,
19
+ markResourcesSubmitted.type,
20
+ clearSubmittedResources.type,
21
+ clearAllResources.type,
22
+ removeInstanceResources.type
23
+ ]);
24
+ function storage() {
25
+ try {
26
+ return typeof window === "undefined" ? null : window.localStorage;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function renderedArtifactDraftConversation(action) {
32
+ if (typeof action !== "object" || action === null) return null;
33
+ const { type, payload } = action;
34
+ if (typeof type !== "string" || !RESOURCE_ACTIONS.has(type)) return null;
35
+ if (typeof payload === "string") return payload;
36
+ if (payload && typeof payload === "object" && typeof payload.conversationId === "string") {
37
+ return payload.conversationId;
38
+ }
39
+ return null;
40
+ }
41
+ const restoreAttempted = /* @__PURE__ */ new Set();
42
+ function persistRenderedArtifactChips(state, conversationId, owner) {
43
+ const store = storage();
44
+ if (!store || !restoreAttempted.has(conversationId)) return;
45
+ const submitted = new Set(state.instanceResources.submittedIds?.[conversationId] ?? []);
46
+ const chips = Object.values(state.instanceResources.byConversationId[conversationId] ?? {}).filter((r) => r.blockType === RENDERED_ARTIFACT_BLOCK_TYPE && !submitted.has(r.resourceId) && r.status !== "error").map((r) => ({
47
+ resourceId: r.resourceId,
48
+ source: r.source,
49
+ options: { ...r.options ?? {} },
50
+ preview: typeof r.preview === "string" ? r.preview : null
51
+ }));
52
+ try {
53
+ if (chips.length === 0) store.removeItem(KEY_PREFIX + conversationId);
54
+ else store.setItem(KEY_PREFIX + conversationId, JSON.stringify({ owner, at: Date.now(), chips }));
55
+ } catch {
56
+ }
57
+ }
58
+ function restoreRenderedArtifactChips(dispatch, getState, conversationId, owner) {
59
+ restoreAttempted.add(conversationId);
60
+ const store = storage();
61
+ if (!store) return 0;
62
+ let record = null;
63
+ try {
64
+ const raw = store.getItem(KEY_PREFIX + conversationId);
65
+ record = raw ? JSON.parse(raw) : null;
66
+ } catch {
67
+ record = null;
68
+ }
69
+ if (!record || !Array.isArray(record.chips)) return 0;
70
+ if (Date.now() - record.at > TTL_MS || (record.owner ?? null) !== (owner ?? null)) {
71
+ store.removeItem(KEY_PREFIX + conversationId);
72
+ return 0;
73
+ }
74
+ const present = getState().instanceResources.byConversationId[conversationId] ?? {};
75
+ let restored = 0;
76
+ for (const chip of record.chips) {
77
+ if (present[chip.resourceId]) continue;
78
+ dispatch(
79
+ addResource({
80
+ conversationId,
81
+ blockType: RENDERED_ARTIFACT_BLOCK_TYPE,
82
+ resourceId: chip.resourceId,
83
+ source: chip.source,
84
+ options: chip.options
85
+ })
86
+ );
87
+ if (chip.preview) dispatch(setResourcePreview({ conversationId, resourceId: chip.resourceId, preview: chip.preview }));
88
+ restored += 1;
89
+ }
90
+ return restored;
91
+ }
92
+ function __resetRenderedArtifactDraftForTest() {
93
+ restoreAttempted.clear();
94
+ }
95
+ export {
96
+ __resetRenderedArtifactDraftForTest,
97
+ persistRenderedArtifactChips,
98
+ renderedArtifactDraftConversation,
99
+ restoreRenderedArtifactChips
100
+ };
@@ -14,6 +14,10 @@ import {
14
14
  } from "./composer-draft-store.js";
15
15
  import { selectRestoreUnsentDrafts } from "../../../../host/prefs.js";
16
16
  import { selectUserId } from "../../../../host/identity.js";
17
+ import {
18
+ persistRenderedArtifactChips,
19
+ renderedArtifactDraftConversation
20
+ } from "../instance-resources/rendered-artifact-draft.js";
17
21
  const WRITE_DEBOUNCE_MS = 400;
18
22
  const timers = /* @__PURE__ */ new Map();
19
23
  const pending = /* @__PURE__ */ new Map();
@@ -91,6 +95,11 @@ const composerDraftMiddleware = (api) => (next) => (action) => {
91
95
  return next(action);
92
96
  }
93
97
  const result = next(action);
98
+ const chipConversation = renderedArtifactDraftConversation(action);
99
+ if (chipConversation) {
100
+ persistRenderedArtifactChips(api.getState(), chipConversation, selectUserId(api.getState()));
101
+ return result;
102
+ }
94
103
  if (type === setUserInputText.type) {
95
104
  const { conversationId, text } = action.payload;
96
105
  scheduleWrite(conversationId, text, selectUserId(api.getState()));
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { restoreRenderedArtifactChips } from "../instance-resources/rendered-artifact-draft.js";
2
3
  import { useCallback, useEffect, useRef, useState } from "react";
3
4
  import { useAppDispatch, useAppSelector, useAppStore } from "../../../../store/hooks.js";
4
5
  import {
@@ -50,12 +51,13 @@ function useComposerDraftRestore(conversationId, alias) {
50
51
  setStorageAvailable(isComposerDraftStorageAvailable());
51
52
  if (!enabled) return;
52
53
  if (chatHostHasDatabase()) restoreComposerRemarks(conversationId);
54
+ restoreRenderedArtifactChips(dispatch, store.getState, conversationId, ownerId ?? null);
53
55
  const token = peekComposerDraft(conversationId, liveAlias, ownerId);
54
56
  if (!token) return;
55
57
  if (dispatch(applyComposerDraft(token)) === "restored") {
56
58
  setRestoredValue(token.value);
57
59
  }
58
- }, [conversationId, liveAlias, dispatch, enabled, entryReady, ownerId]);
60
+ }, [conversationId, liveAlias, dispatch, store, enabled, entryReady, ownerId]);
59
61
  useEffect(() => {
60
62
  if (restoredValue !== null && text !== restoredValue) setRestoredValue(null);
61
63
  }, [text, restoredValue]);
@@ -12,23 +12,33 @@ import { removeConversationFromScopes } from "../../conversation-history/slice.j
12
12
  import { findConversationRow } from "../../conversation-list/conversation-list.selectors.js";
13
13
  import { clearCacheBypass } from "./cache-bypass.slice.js";
14
14
  import { invalidateConversationCache } from "./invalidate-conversation-cache.thunk.js";
15
+ import { adminSeatOpen } from "../../../../host/admin-seat.js";
16
+ import { callConversationDelete } from "../../../../host/server/call-api.js";
15
17
  const softDeleteConversation = createAsyncThunk(
16
18
  "conversations/softDelete",
17
19
  async ({ conversationId }, { dispatch, getState, rejectWithValue }) => {
18
20
  const snapshot = findConversationRow(getState(), conversationId);
19
- const { data, error } = await supabase.rpc("cx_soft_delete_conversation", {
20
- p_conversation_id: conversationId
21
- });
22
- if (error) {
23
- return rejectWithValue({
24
- message: operationFailed("delete this conversation", error).message
25
- });
26
- }
27
- const deleted = data === true;
28
- if (!deleted) {
29
- return rejectWithValue({
30
- message: operationFailed("delete this conversation").message
21
+ if (adminSeatOpen(getState())) {
22
+ const result = await dispatch(callConversationDelete({ conversationId }));
23
+ if (result.error || result.data?.deleted !== true) {
24
+ return rejectWithValue({
25
+ message: operationFailed("delete this conversation", result.error ?? void 0).message
26
+ });
27
+ }
28
+ } else {
29
+ const { data, error } = await supabase.rpc("cx_soft_delete_conversation", {
30
+ p_conversation_id: conversationId
31
31
  });
32
+ if (error) {
33
+ return rejectWithValue({
34
+ message: operationFailed("delete this conversation", error).message
35
+ });
36
+ }
37
+ if (data !== true) {
38
+ return rejectWithValue({
39
+ message: operationFailed("delete this conversation").message
40
+ });
41
+ }
32
42
  }
33
43
  void dispatch(invalidateConversationCache({ conversationId }));
34
44
  dispatch(clearCacheBypass(conversationId));
@@ -617,7 +617,7 @@ const executeInstance = createAsyncThunk(
617
617
  }
618
618
  })
619
619
  );
620
- const submitAt = performance.now();
620
+ const submitAt = submission?.capturedAt ?? performance.now();
621
621
  const RUN_IN_FLIGHT_RETRY_MS = 750;
622
622
  const RUN_IN_FLIGHT_DEADLINE = Date.now() + 12e4;
623
623
  for (; ; ) {
@@ -560,7 +560,7 @@ const executeManualInstance = createAsyncThunk(
560
560
  requestId,
561
561
  sandboxAttached: payload.sandbox != null || payload.client?.state?.["sandbox-fs"] != null
562
562
  });
563
- const submitAt = performance.now();
563
+ const submitAt = submission.capturedAt ?? performance.now();
564
564
  const abortController = new AbortController();
565
565
  registerAbortController(conversationId, abortController);
566
566
  try {
@@ -31,6 +31,11 @@ export interface FrozenSubmission {
31
31
  editorResourceXml: string;
32
32
  /** The variable values the person had when they pressed send. */
33
33
  userValues: Record<string, unknown>;
34
+ /**
35
+ * `performance.now()` at the keypress — t=0 for every client timing (prep, TTFT). Absent
36
+ * on a submission built after the fact (a resend); the send then stamps its own.
37
+ */
38
+ capturedAt?: number;
34
39
  }
35
40
  export declare function captureSubmission(state: ChatRootState, conversationId: string): FrozenSubmission;
36
41
  /** True when the submission carries nothing a person sent. */
@@ -15,7 +15,8 @@ function captureSubmission(state, conversationId) {
15
15
  editorResourceXml: selectEditorResourceXml(conversationId)(state) ?? "",
16
16
  userValues: {
17
17
  ...state.instanceVariableValues?.byConversationId[conversationId]?.userValues ?? {}
18
- }
18
+ },
19
+ capturedAt: performance.now()
19
20
  };
20
21
  }
21
22
  function isEmptySubmission(submission) {