@ai-matrx/chat 0.4.15 → 0.4.16

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,5 +1,25 @@
1
1
  # @ai-matrx/chat
2
2
 
3
+ ## 0.4.16 — the Side Chat's URL contract
4
+
5
+ - ONE param family on every home opens, closes and aims the Side Chat (the shell chat dock):
6
+ `side_chat` = `open` | `closed` | `new` | `<conversation uuid>`; `side_chat_agent` =
7
+ `<agent uuid>` | `mandate:<key>`; `side_chat_mode` = `chat` | `work` | `advanced`. The link wins
8
+ over the remembered conversation, the family cookie and the defaults for its visit, and never
9
+ writes the person's open / closed choice. Invalid values are ignored with a `[side-chat]` warning.
10
+ - After reading, the one-shot params are consumed; the address names the shown conversation as
11
+ `side_chat=<uuid>` while the panel is open, and drops it when the person closes the panel.
12
+ - `/chat` and `/code` show the Side Chat when (and only when) a `side_chat*` param asks.
13
+ - New module `canvas/workspace/side-chat-address` (`readSideChatLink`, `sideChatOpen`,
14
+ `addressAfterSideChatLink`, `sideChatAddress`, `sideChatSignature`, the param names).
15
+ - Removed (no legacy): `?pageChat=` and the boards / Education `?chat=`, `ShellChatHome.addressParam`,
16
+ `conversationInAddress`, `addressWithConversation`; `useCanvasWorkspaceConversation`'s
17
+ `addressParam` option is now `address: { link, open, hostedElsewhere, onWrite? }`.
18
+
19
+ Consumer action: any link that opened the dock with `?pageChat=<id>` or a board's / Education's
20
+ `?chat=<id>` now writes `?side_chat=<id>` (matrx-frontend: `continueWithAgentHref`). A host calling
21
+ `useCanvasWorkspaceConversation` with `addressParam` passes `address` instead.
22
+
3
23
  ## 0.4.15
4
24
 
5
25
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -2,7 +2,7 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useEffectEvent, useRef, useState } from "react";
4
4
  import { Maximize2, PictureInPicture2 } from "lucide-react";
5
- import { usePathname } from "../../host/navigation.js";
5
+ import { usePathname, useSearchParams } from "../../host/navigation.js";
6
6
  import {
7
7
  Drawer,
8
8
  DrawerContent,
@@ -23,6 +23,14 @@ import { CanvasChatColumn } from "./CanvasChatColumn.js";
23
23
  import { ChatPanelTitleMenu, useChatPanelTitle } from "./ChatPanelTitleMenu.js";
24
24
  import { useCanvasWorkspaceConversation } from "./useCanvasWorkspaceConversation.js";
25
25
  import { useShellChatPageContext } from "./shell-chat-page-context.js";
26
+ import { useComposerMode } from "../../agents/components/inputs/smart-input/composer/useComposerMode.js";
27
+ import { replaceAddressWithoutNavigating } from "@ai-matrx/rich-content/utils/addressWithoutNavigating";
28
+ import {
29
+ addressAfterSideChatLink,
30
+ readSideChatLink,
31
+ sideChatOpen,
32
+ sideChatSignature
33
+ } from "./side-chat-address.js";
26
34
  import {
27
35
  CANVAS_CHAT_SIZES,
28
36
  CANVAS_PANEL_IDS,
@@ -77,6 +85,8 @@ function useCanvasFullScreenActive() {
77
85
  }
78
86
  function ShellChatDock({ initialChat, initialWidth, initialMode = null, signedIn, initialPathname }) {
79
87
  const pathname = usePathname() ?? initialPathname;
88
+ const searchParams = useSearchParams();
89
+ const linkSignature = sideChatSignature((name) => searchParams?.get(name) ?? null);
80
90
  const home = shellChatHome(pathname, signedIn);
81
91
  const hostedElsewhere = shellChatHostedElsewhere(pathname);
82
92
  const viewportCompact = useMediaQueryState(COMPACT_QUERY);
@@ -88,6 +98,9 @@ function ShellChatDock({ initialChat, initialWidth, initialMode = null, signedIn
88
98
  });
89
99
  const [wideDefault, setWideDefault] = useState(false);
90
100
  const [sheetOpen, setSheetOpen] = useState(false);
101
+ const [visit, setVisit] = useState(null);
102
+ const ownWrite = useRef(null);
103
+ const { setMode } = useComposerMode(initialMode, { enabled: signedIn });
91
104
  if (choice.layoutId !== home.layoutId) {
92
105
  setChoice({
93
106
  layoutId: home.layoutId,
@@ -97,21 +110,57 @@ function ShellChatDock({ initialChat, initialWidth, initialMode = null, signedIn
97
110
  useEffect(() => {
98
111
  setWideDefault(window.matchMedia(SHELL_CHAT_WIDE_QUERY).matches);
99
112
  }, []);
113
+ const visitHere = visit && visit.pathname === pathname && visit.surfaceKey === home.surfaceKey ? visit : null;
114
+ const link = visitHere?.link ?? null;
115
+ const standsAside = hostedElsewhere && !link;
100
116
  const placement = choice.state?.placement ?? "side";
101
- const open = !hostedElsewhere && (choice.state?.open ?? home.defaultOpen ?? wideDefault);
117
+ const open = sideChatOpen({
118
+ hostedElsewhere,
119
+ link,
120
+ personActed: visitHere?.acted ?? false,
121
+ remembered: choice.state?.open ?? home.defaultOpen ?? wideDefault
122
+ });
102
123
  const shown = open && !fullScreen;
103
124
  const docked = shown && placement === "side";
104
125
  const floating = !compact && shown && placement === "floating";
105
- const chatOnScreen = !hostedElsewhere && (compact ? sheetOpen : viewportCompact === false && shown);
126
+ const chatOnScreen = !standsAside && (compact ? sheetOpen : viewportCompact === false && shown);
127
+ const readLink = useEffectEvent(() => {
128
+ const search = window.location.search;
129
+ if (visitHere && ownWrite.current === search) return;
130
+ const read = readSideChatLink(search);
131
+ setVisit({ pathname, surfaceKey: home.surfaceKey, link: read, acted: false });
132
+ if (!linkSignature) return;
133
+ const consumed = addressAfterSideChatLink(search, read, { hostedElsewhere });
134
+ ownWrite.current = consumed;
135
+ if (consumed !== search) {
136
+ replaceAddressWithoutNavigating(`${window.location.pathname}${consumed}${window.location.hash}`);
137
+ }
138
+ if (!read) return;
139
+ const opens = read.open ?? (hostedElsewhere ? true : null);
140
+ if (opens !== null) setSheetOpen(opens);
141
+ if (opens) window.dispatchEvent(new Event(SHELL_CHAT_REVEAL_EVENT));
142
+ if (read.mode) setMode(read.mode);
143
+ });
144
+ useEffect(() => {
145
+ if (signedIn) readLink();
146
+ }, [signedIn, pathname, home.surfaceKey, linkSignature]);
106
147
  const chat = useCanvasWorkspaceConversation(home.surfaceKey, {
107
148
  enabled: signedIn && chatOnScreen,
108
- addressParam: home.addressParam
149
+ address: {
150
+ link: visitHere ? visitHere.link : void 0,
151
+ open: compact ? sheetOpen : open,
152
+ hostedElsewhere,
153
+ onWrite: (search) => {
154
+ ownWrite.current = search;
155
+ }
156
+ }
109
157
  });
110
158
  const conversationId = chat.conversationId;
111
159
  const chatTitle = useChatPanelTitle(conversationId);
112
160
  const pageContext = useShellChatPageContext();
113
161
  const floatingSize = readFloatingSize(useSessionKnob(COMPOSER_KNOBS.floatingPanelSize));
114
162
  const setChat = (next) => {
163
+ setVisit((current) => current && !current.acted ? { ...current, acted: true } : current);
115
164
  setChoice({ layoutId: home.layoutId, state: next });
116
165
  writeCanvasChatCookie(home.layoutId, next);
117
166
  };
@@ -132,14 +181,14 @@ function ShellChatDock({ initialChat, initialWidth, initialMode = null, signedIn
132
181
  const root = document.querySelector(".shell-root");
133
182
  if (!root || !signedIn) return void 0;
134
183
  root.toggleAttribute("data-shell-chat-open", docked && wideKnown);
135
- root.toggleAttribute("data-shell-chat-available", !hostedElsewhere);
184
+ root.toggleAttribute("data-shell-chat-available", !standsAside);
136
185
  return () => {
137
186
  root.removeAttribute("data-shell-chat-open");
138
187
  root.removeAttribute("data-shell-chat-available");
139
188
  };
140
- }, [docked, wideKnown, hostedElsewhere, signedIn]);
189
+ }, [docked, wideKnown, standsAside, signedIn]);
141
190
  useEffect(() => {
142
- if (!signedIn || !wideKnown || hostedElsewhere) return;
191
+ if (!signedIn || !wideKnown || standsAside) return;
143
192
  const root = document.querySelector(".shell-root");
144
193
  const sidebarToggle = document.getElementById("shell-sidebar-toggle");
145
194
  if (!root?.hasAttribute("data-domain-panel") || !sidebarToggle) return;
@@ -155,17 +204,18 @@ function ShellChatDock({ initialChat, initialWidth, initialMode = null, signedIn
155
204
  const want = saved?.split("=")[1] !== "0";
156
205
  if (sidebarToggle.checked !== want) sidebarToggle.checked = want;
157
206
  }
158
- }, [docked, choice.state, wideKnown, hostedElsewhere, signedIn, home.layoutId]);
207
+ }, [docked, choice.state, wideKnown, standsAside, signedIn, home.layoutId]);
159
208
  const onToggle = useEffectEvent(() => {
160
- if (!hostedElsewhere) toggle();
209
+ if (!standsAside) toggle();
161
210
  });
211
+ const takesToggle = useEffectEvent(() => !standsAside);
162
212
  useEffect(() => {
163
213
  const onEvent = () => onToggle();
164
214
  const onKey = (e) => {
165
215
  if (!signedIn || !shellChatTakesToggleKey(e)) return;
166
216
  const inChat = e.target instanceof Element && e.target.closest(".shell-chat-dock, .shell-chat-floating");
167
217
  if (isTypingTarget(e.target) && !inChat) return;
168
- if (shellChatHostedElsewhere(window.location.pathname)) return;
218
+ if (!takesToggle()) return;
169
219
  e.preventDefault();
170
220
  onToggle();
171
221
  };
@@ -30,7 +30,8 @@ export declare function shellChatWorkspaceId(family: string): string;
30
30
  * The only pages the shell's chat stands aside on: the full chat itself and
31
31
  * the code workspace (it docks its own coding agent). Every other page —
32
32
  * the Board and Education included — shows THE chat (owner, 2026-10-05: one
33
- * chat, on every page).
33
+ * chat, on every page). A side_chat* link brings it in here too
34
+ * (side-chat-address.ts `sideChatOpen`, owner 2026-10-07).
34
35
  */
35
36
  export declare function shellChatHostedElsewhere(pathname: string): boolean;
36
37
  /**
@@ -46,14 +47,14 @@ export declare function shellChatPageSurfaceKey(pathname: string): string;
46
47
  * - `layoutId` — its remembered open / closed + docked / floating
47
48
  * (`canvasChatCookieName(layoutId)`);
48
49
  * - `surfaceKey` — the conversation it shows (and this device's memory of it);
49
- * - `addressParam` — the query param that conversation lives at;
50
+ * the address names it as `?side_chat=` on every home
51
+ * (side-chat-address.ts);
50
52
  * - `defaultOpen` — open with no remembered choice; null = open on a wide
51
53
  * screen only (SHELL_CHAT_WIDE_QUERY).
52
54
  */
53
55
  export interface ShellChatHome {
54
56
  layoutId: string;
55
57
  surfaceKey: string;
56
- addressParam: string;
57
58
  defaultOpen: boolean | null;
58
59
  }
59
60
  /**
@@ -64,7 +65,7 @@ export interface ShellChatHome {
64
65
  export declare const SHELL_CHAT_CLOSED_BY_DEFAULT_PAGES: readonly RegExp[];
65
66
  /**
66
67
  * A page with a chat of its OWN keeps it inside the shell chat: each board
67
- * has its conversation, signed-in Education has one. The ids, params and
68
+ * has its conversation, signed-in Education has one. The ids and
68
69
  * defaults are exactly the ones those pages used when they drew their own chat
69
70
  * column (ChatCanvasWorkspace, until 2026-10-05), so a person's remembered
70
71
  * layout and conversation carry over untouched. Every other page remembers its
@@ -31,16 +31,14 @@ function shellChatHome(pathname, signedIn) {
31
31
  const board = BOARD_PAGE.exec(pathname);
32
32
  if (board) {
33
33
  const layoutId = `board-${board[1]}`;
34
- return { layoutId, surfaceKey: `canvas-workspace:${layoutId}`, addressParam: "chat", defaultOpen: true };
34
+ return { layoutId, surfaceKey: `canvas-workspace:${layoutId}`, defaultOpen: true };
35
35
  }
36
36
  if (signedIn && EDUCATION_PAGES.test(pathname)) {
37
- return { layoutId: "education", surfaceKey: "canvas-workspace:education", addressParam: "chat", defaultOpen: false };
37
+ return { layoutId: "education", surfaceKey: "canvas-workspace:education", defaultOpen: false };
38
38
  }
39
39
  return {
40
40
  layoutId: shellChatWorkspaceId(shellChatFamily(pathname)),
41
41
  surfaceKey: shellChatPageSurfaceKey(pathname),
42
- // `pageChat`, not `chat`: /code keeps its own `?chat=`.
43
- addressParam: "pageChat",
44
42
  defaultOpen: SHELL_CHAT_CLOSED_BY_DEFAULT_PAGES.some((p) => p.test(pathname)) ? false : null
45
43
  };
46
44
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * THE SIDE CHAT'S LINK CONTRACT (owner, 2026-10-07) — the ONE param family that
3
+ * opens, closes and aims the chat beside every page, on every home (an ordinary
4
+ * page, a board, Education — and /chat and /code, where the Side Chat is absent
5
+ * unless a link asks for it).
6
+ *
7
+ * side_chat open | closed | new | <conversation uuid>
8
+ * side_chat_agent <agent uuid> | mandate:<key> (the agent picker's ref shape)
9
+ * side_chat_mode chat | work | advanced (the composer's mode)
10
+ *
11
+ * The link WINS over the remembered state (the per-page conversation, the family
12
+ * cookie, the defaults) for that visit; it never writes the person's open / closed
13
+ * choice — only the person's own act does. Once read, the address keeps naming
14
+ * what the panel shows: `side_chat=<uuid>` for a conversation the server has,
15
+ * `side_chat=open` / `closed` otherwise; `side_chat_agent` / `side_chat_mode` and
16
+ * `side_chat=new` are consumed so a reload never starts yet another new chat.
17
+ *
18
+ * Plain module, no React: the dock, the conversation hook and the tests share it.
19
+ * A value it cannot use is IGNORED with a console warning naming it — never silently.
20
+ */
21
+ import { type AnyMandateKey } from "@ai-matrx/agents/mandates";
22
+ import { type ComposerMode } from "../../agents/components/inputs/smart-input/composer/composer-types.js";
23
+ export declare const SIDE_CHAT_PARAM = "side_chat";
24
+ export declare const SIDE_CHAT_AGENT_PARAM = "side_chat_agent";
25
+ export declare const SIDE_CHAT_MODE_PARAM = "side_chat_mode";
26
+ export declare const SIDE_CHAT_PARAMS: readonly ["side_chat", "side_chat_agent", "side_chat_mode"];
27
+ /** What a link asks the Side Chat's conversation to be. */
28
+ export type SideChatLinkConversation = {
29
+ kind: "new";
30
+ } | {
31
+ kind: "open";
32
+ conversationId: string;
33
+ } | {
34
+ kind: "agent";
35
+ agentId: string;
36
+ } | {
37
+ kind: "mandate";
38
+ mandateKey: AnyMandateKey;
39
+ agentRef: string;
40
+ };
41
+ export interface SideChatLink {
42
+ /** true / false = the link sets the panel; null = it says nothing about it. */
43
+ open: boolean | null;
44
+ conversation: SideChatLinkConversation | null;
45
+ mode: ComposerMode | null;
46
+ }
47
+ export type SideChatWarn = (message: string) => void;
48
+ /**
49
+ * The Side Chat link in `search`, or null when it carries no usable side_chat*
50
+ * instruction (absent, or every value invalid — each invalid one is warned).
51
+ */
52
+ export declare function readSideChatLink(search: string, warn?: SideChatWarn): SideChatLink | null;
53
+ /**
54
+ * Whether the Side Chat is open. The link wins for its visit until the person
55
+ * acts; then their remembered choice does again. On a page that hosts its own
56
+ * chat (/chat, /code) the Side Chat is absent unless a link summoned it, and a
57
+ * link that names no open / closed (only a mode) still brings it in.
58
+ */
59
+ export declare function sideChatOpen({ hostedElsewhere, link, personActed, remembered, }: {
60
+ hostedElsewhere: boolean;
61
+ /** This visit's link; null = none. */
62
+ link: SideChatLink | null;
63
+ /** The person opened / closed / docked the chat since the link was read. */
64
+ personActed: boolean;
65
+ /** The remembered choice, else the home's default. */
66
+ remembered: boolean;
67
+ }): boolean;
68
+ /**
69
+ * The address right after a link is read: `side_chat_agent`, `side_chat_mode`,
70
+ * `side_chat=new` and any invalid side_chat* value are consumed, and `side_chat`
71
+ * says what the panel now is (`open` / `closed`, or the conversation id it was
72
+ * asked to open). Everything else in the address is untouched.
73
+ */
74
+ export declare function addressAfterSideChatLink(search: string, link: SideChatLink | null, { hostedElsewhere }: {
75
+ hostedElsewhere: boolean;
76
+ }): string;
77
+ /**
78
+ * The address while the Side Chat shows a conversation: open on a conversation
79
+ * the server has → `side_chat=<uuid>` (a copied link reproduces it); open on an
80
+ * unsent chat → `side_chat=open` where it already said so or where the page
81
+ * hosts its own chat (else nothing, as before); closed → no conversation in the
82
+ * address (a `side_chat=closed` the link set stays). The one-shot params never
83
+ * survive.
84
+ */
85
+ export declare function sideChatAddress(search: string, { conversationId, open, hostedElsewhere }: {
86
+ conversationId: string | null;
87
+ open: boolean;
88
+ hostedElsewhere: boolean;
89
+ }): string;
90
+ /** The side_chat* values of `search`, as one comparable string ("" when none). */
91
+ export declare function sideChatSignature(get: (name: string) => string | null): string;
@@ -0,0 +1,110 @@
1
+ import { isUuidShape } from "@ai-matrx/kit/uuid";
2
+ import { isMandateAgentId, mandateAgentId } from "@ai-matrx/agents/catalog";
3
+ import { storedMandateKey } from "@ai-matrx/agents/mandates";
4
+ import { isComposerMode } from "../../agents/components/inputs/smart-input/composer/composer-types.js";
5
+ const SIDE_CHAT_PARAM = "side_chat";
6
+ const SIDE_CHAT_AGENT_PARAM = "side_chat_agent";
7
+ const SIDE_CHAT_MODE_PARAM = "side_chat_mode";
8
+ const SIDE_CHAT_PARAMS = [SIDE_CHAT_PARAM, SIDE_CHAT_AGENT_PARAM, SIDE_CHAT_MODE_PARAM];
9
+ const defaultWarn = (message) => console.warn(`[side-chat] ${message}`);
10
+ const MANDATE_KEY_SHAPE = /^[a-z0-9][a-z0-9_.:-]*$/i;
11
+ function readAgent(value, warn) {
12
+ if (isUuidShape(value)) return { kind: "agent", agentId: value };
13
+ if (isMandateAgentId(value)) {
14
+ const key = value.slice("mandate:".length);
15
+ if (MANDATE_KEY_SHAPE.test(key)) {
16
+ return { kind: "mandate", mandateKey: storedMandateKey(key), agentRef: mandateAgentId(key) };
17
+ }
18
+ }
19
+ warn(`ignored ${SIDE_CHAT_AGENT_PARAM}="${value}": expected an agent id or mandate:<key>`);
20
+ return null;
21
+ }
22
+ function readSideChatLink(search, warn = defaultWarn) {
23
+ const params = new URLSearchParams(search);
24
+ const rawChat = params.get(SIDE_CHAT_PARAM);
25
+ const rawAgent = params.get(SIDE_CHAT_AGENT_PARAM);
26
+ const rawMode = params.get(SIDE_CHAT_MODE_PARAM);
27
+ if (rawChat === null && rawAgent === null && rawMode === null) return null;
28
+ let open = null;
29
+ let conversation = null;
30
+ if (rawChat !== null) {
31
+ if (rawChat === "open") open = true;
32
+ else if (rawChat === "closed") open = false;
33
+ else if (rawChat === "new") {
34
+ open = true;
35
+ conversation = { kind: "new" };
36
+ } else if (isUuidShape(rawChat)) {
37
+ open = true;
38
+ conversation = { kind: "open", conversationId: rawChat };
39
+ } else {
40
+ warn(`ignored ${SIDE_CHAT_PARAM}="${rawChat}": expected open, closed, new or a conversation id`);
41
+ }
42
+ }
43
+ if (rawAgent !== null) {
44
+ const agent = readAgent(rawAgent, warn);
45
+ if (agent) {
46
+ if (conversation?.kind === "open") {
47
+ warn(`${SIDE_CHAT_AGENT_PARAM} wins over ${SIDE_CHAT_PARAM}=${conversation.conversationId}: a new chat with that agent opens`);
48
+ }
49
+ if (open === false) warn(`${SIDE_CHAT_AGENT_PARAM} wins over ${SIDE_CHAT_PARAM}=closed: the chat opens`);
50
+ conversation = agent;
51
+ open = true;
52
+ }
53
+ }
54
+ let mode = null;
55
+ if (rawMode !== null) {
56
+ if (isComposerMode(rawMode)) mode = rawMode;
57
+ else warn(`ignored ${SIDE_CHAT_MODE_PARAM}="${rawMode}": expected chat, work or advanced`);
58
+ }
59
+ if (open === null && conversation === null && mode === null) return null;
60
+ return { open, conversation, mode };
61
+ }
62
+ function sideChatOpen({
63
+ hostedElsewhere,
64
+ link,
65
+ personActed,
66
+ remembered
67
+ }) {
68
+ if (hostedElsewhere && !link) return false;
69
+ if (link && !personActed) return link.open ?? (hostedElsewhere ? true : remembered);
70
+ return remembered;
71
+ }
72
+ function addressAfterSideChatLink(search, link, { hostedElsewhere }) {
73
+ const params = new URLSearchParams(search);
74
+ for (const name of SIDE_CHAT_PARAMS) params.delete(name);
75
+ const open = link ? link.open ?? (hostedElsewhere ? true : null) : null;
76
+ if (open !== null) {
77
+ const named = link?.conversation?.kind === "open" ? link.conversation.conversationId : null;
78
+ params.set(SIDE_CHAT_PARAM, open ? named ?? "open" : "closed");
79
+ }
80
+ const qs = params.toString();
81
+ return qs ? `?${qs}` : "";
82
+ }
83
+ function sideChatAddress(search, { conversationId, open, hostedElsewhere }) {
84
+ const params = new URLSearchParams(search);
85
+ const current = params.get(SIDE_CHAT_PARAM);
86
+ params.delete(SIDE_CHAT_AGENT_PARAM);
87
+ params.delete(SIDE_CHAT_MODE_PARAM);
88
+ if (open && conversationId) params.set(SIDE_CHAT_PARAM, conversationId);
89
+ else if (open) {
90
+ if (current === "open" || hostedElsewhere) params.set(SIDE_CHAT_PARAM, "open");
91
+ else params.delete(SIDE_CHAT_PARAM);
92
+ } else if (current !== "closed") params.delete(SIDE_CHAT_PARAM);
93
+ const qs = params.toString();
94
+ return qs ? `?${qs}` : "";
95
+ }
96
+ function sideChatSignature(get) {
97
+ const parts = SIDE_CHAT_PARAMS.map((name) => get(name));
98
+ return parts.every((p) => p === null) ? "" : parts.map((p) => p ?? "").join("|");
99
+ }
100
+ export {
101
+ SIDE_CHAT_AGENT_PARAM,
102
+ SIDE_CHAT_MODE_PARAM,
103
+ SIDE_CHAT_PARAM,
104
+ SIDE_CHAT_PARAMS,
105
+ addressAfterSideChatLink,
106
+ readSideChatLink,
107
+ sideChatAddress,
108
+ sideChatOpen,
109
+ sideChatSignature
110
+ };
@@ -1,3 +1,4 @@
1
+ import { type SideChatLink } from "./side-chat-address.js";
1
2
  import type { AnyMandateKey } from "@ai-matrx/agents/mandates";
2
3
  export type CanvasWorkspaceConversation = {
3
4
  state: "opening";
@@ -39,18 +40,28 @@ export type CanvasWorkspaceConversationOptions = {
39
40
  */
40
41
  surfaceName?: null;
41
42
  /**
42
- * The query param this conversation lives at (`?chat=<id>`), so a reload
43
- * returns the person to it the way `/chat/<id>` does. Read once on mount
44
- * (it wins over the default new chat), written once the server has the
45
- * conversation, removed by New chat. With no `?<param>=`, the conversation
46
- * this chat last showed on this device opens instead (see
47
- * `recallWorkspaceConversation`). Omitted = neither the address nor the memory
48
- * is touched (board chat TILES: many conversations, one page).
43
+ * THE SIDE CHAT'S ADDRESS (side-chat-address.ts): the conversation lives at
44
+ * `?side_chat=<id>`, so a reload returns the person to it the way `/chat/<id>`
45
+ * does. The link is read once per home (it wins over the remembered
46
+ * conversation and the default new chat); a later link on the same home is
47
+ * applied when it arrives. The address names the conversation once the server
48
+ * has it and while the panel is open. With no link, the conversation this
49
+ * chat last showed on this device opens instead (`recallWorkspaceConversation`).
50
+ * Omitted = neither the address nor the memory is touched (board chat TILES:
51
+ * many conversations, one page).
49
52
  */
50
- addressParam?: string;
53
+ address?: SideChatAddressOptions;
51
54
  };
52
- /** The conversation `?<param>=` names in the current address, or null. */
53
- export declare function conversationInAddress(param: string, search: string): string | null;
55
+ export interface SideChatAddressOptions {
56
+ /** This visit's link: undefined = not read yet (nothing launches), null = none. */
57
+ link: SideChatLink | null | undefined;
58
+ /** The panel is open — the address names the conversation only while it is. */
59
+ open: boolean;
60
+ /** The page hosts its own chat (/chat, /code): an unsent chat keeps `side_chat=open`. */
61
+ hostedElsewhere: boolean;
62
+ /** Told every address this hook writes, so the reader never re-reads its own write as a link. */
63
+ onWrite?: (search: string) => void;
64
+ }
54
65
  /**
55
66
  * THIS CHAT'S LAST CONVERSATION, remembered on this device (2026-10-05).
56
67
  * `?<param>=` alone was not enough: any address without it — the board opened
@@ -74,8 +85,6 @@ export declare function rememberWorkspaceConversation(surfaceKey: string, conver
74
85
  export declare function reservedChatKey(surfaceKey: string): string;
75
86
  export declare function reserveWorkspaceConversationId(surfaceKey: string, fresh: boolean): string | undefined;
76
87
  export declare function releaseWorkspaceConversationId(surfaceKey: string): void;
77
- /** `search` with `?<param>=` set to `conversationId`, or removed when null. */
78
- export declare function addressWithConversation(param: string, search: string, conversationId: string | null): string;
79
88
  export interface CanvasWorkspaceConversationController {
80
89
  conversation: CanvasWorkspaceConversation;
81
90
  /** The id when ready, else null. */
@@ -9,6 +9,7 @@ import { selectShouldPromptForOrganization } from "../../host/ui-slots.js";
9
9
  import { selectIsCacheOnly } from "../../agents/redux/execution-system/conversations/conversations.selectors.js";
10
10
  import { replaceAddressWithoutNavigating } from "@ai-matrx/rich-content/utils/addressWithoutNavigating";
11
11
  import { describeLaunchError } from "./describe-launch-error.js";
12
+ import { sideChatAddress } from "./side-chat-address.js";
12
13
  import { selectOrganizationId, ensureOrganizationContext, isOrganizationSelectionCancelled } from "../../host/org.js";
13
14
  function initialRequest(start) {
14
15
  if (start?.kind === "agent") return { kind: "agent", agentId: start.agentId, nonce: 0 };
@@ -16,9 +17,13 @@ function initialRequest(start) {
16
17
  return { kind: "open", conversationId: start.conversationId, agentId: start.agentId ?? null, nonce: 0 };
17
18
  return { kind: "new", nonce: 0 };
18
19
  }
19
- function conversationInAddress(param, search) {
20
- const value = new URLSearchParams(search).get(param);
21
- return value && isUuidShape(value) ? value : null;
20
+ function requestFromLink(link) {
21
+ const c = link?.conversation;
22
+ if (!c) return null;
23
+ if (c.kind === "new") return { kind: "new", nonce: 1 };
24
+ if (c.kind === "agent") return { kind: "agent", agentId: c.agentId, nonce: 1 };
25
+ if (c.kind === "mandate") return { kind: "new", nonce: 1, mandateKey: c.mandateKey };
26
+ return { kind: "open", conversationId: c.conversationId, agentId: null, nonce: 0 };
22
27
  }
23
28
  function workspaceChatMemoryKey(surfaceKey) {
24
29
  return `matrx:workspace-chat:${surfaceKey}`;
@@ -60,29 +65,30 @@ function releaseWorkspaceConversationId(surfaceKey) {
60
65
  } catch {
61
66
  }
62
67
  }
63
- function addressWithConversation(param, search, conversationId) {
64
- const params = new URLSearchParams(search);
65
- if (conversationId) params.set(param, conversationId);
66
- else params.delete(param);
67
- const qs = params.toString();
68
- return qs ? `?${qs}` : "";
69
- }
70
68
  function isCanvasWorkspaceStart(input) {
71
69
  return "kind" in input;
72
70
  }
73
71
  function resolveStartOptions(input) {
74
- if (!input) return { enabled: true, start: void 0, ownSurface: false, addressParam: null };
72
+ if (!input) return { enabled: true, start: void 0, ownSurface: false, address: null };
75
73
  if (isCanvasWorkspaceStart(input))
76
- return { enabled: true, start: input, ownSurface: false, addressParam: null };
74
+ return { enabled: true, start: input, ownSurface: false, address: null };
77
75
  return {
78
76
  enabled: input.enabled ?? true,
79
77
  start: input.start,
80
78
  ownSurface: input.surfaceName === null,
81
- addressParam: input.addressParam ?? null
79
+ address: input.address ?? null
82
80
  };
83
81
  }
84
82
  function useCanvasWorkspaceConversation(surfaceKey, input) {
85
- const { enabled, start, ownSurface, addressParam } = resolveStartOptions(input);
83
+ const { enabled, start, ownSurface, address } = resolveStartOptions(input);
84
+ const hasAddress = address !== null;
85
+ const link = address?.link;
86
+ const addressOpen = address?.open ?? false;
87
+ const hostedElsewhere = address?.hostedElsewhere ?? false;
88
+ const onAddressWrite = useRef(address?.onWrite);
89
+ useEffect(() => {
90
+ onAddressWrite.current = address?.onWrite;
91
+ });
86
92
  const dispatch = useAppDispatch();
87
93
  const { launchMandate, launchAgent } = useAgentLauncher();
88
94
  const [request, setRequest] = useState(() => initialRequest(start));
@@ -92,39 +98,57 @@ function useCanvasWorkspaceConversation(surfaceKey, input) {
92
98
  const organizationId = useAppSelector(selectOrganizationId);
93
99
  const promptForOrganization = useAppSelector(selectShouldPromptForOrganization);
94
100
  const waitingForOrganization = request.kind !== "open" && !organizationId;
95
- const [addressRead, setAddressRead] = useState(addressParam === null);
101
+ const [addressRead, setAddressRead] = useState(!hasAddress);
102
+ const appliedLink = useRef(null);
96
103
  const serverHasIt = useAppSelector(
97
104
  (state) => conversationId ? !selectIsCacheOnly(conversationId)(state) : false
98
105
  );
99
- const [keyedFor, setKeyedFor] = useState({ surfaceKey, addressParam });
100
- if (keyedFor.surfaceKey !== surfaceKey || keyedFor.addressParam !== addressParam) {
101
- setKeyedFor({ surfaceKey, addressParam });
106
+ const [keyedFor, setKeyedFor] = useState({ surfaceKey, hasAddress });
107
+ if (keyedFor.surfaceKey !== surfaceKey || keyedFor.hasAddress !== hasAddress) {
108
+ setKeyedFor({ surfaceKey, hasAddress });
102
109
  setRequest(initialRequest(start));
103
110
  setConversationId(null);
104
111
  setFailure(null);
105
- setAddressRead(addressParam === null);
112
+ setAddressRead(!hasAddress);
106
113
  }
107
114
  useEffect(() => {
108
- if (addressRead || addressParam === null) return;
109
- const named = conversationInAddress(addressParam, window.location.search);
110
- const recalled = named ? null : recallWorkspaceConversation(surfaceKey);
111
- const target = named ?? recalled;
112
- if (target && !start) {
113
- setRequest(
114
- (current) => current.kind === "new" && current.nonce === 0 ? { kind: "open", conversationId: target, agentId: null, nonce: 0, recalled: !named } : current
115
- );
115
+ if (addressRead || !hasAddress || link === void 0) return;
116
+ appliedLink.current = link;
117
+ const linked = requestFromLink(link);
118
+ if (linked && !start) setRequest(linked);
119
+ else if (!start) {
120
+ const recalled = recallWorkspaceConversation(surfaceKey);
121
+ if (recalled) {
122
+ setRequest(
123
+ (current) => current.kind === "new" && current.nonce === 0 ? { kind: "open", conversationId: recalled, agentId: null, nonce: 0, recalled: true } : current
124
+ );
125
+ }
116
126
  }
117
127
  setAddressRead(true);
118
- }, [addressRead, addressParam, start, surfaceKey]);
128
+ }, [addressRead, hasAddress, link, start, surfaceKey]);
119
129
  useEffect(() => {
120
- if (addressParam === null || !addressRead || !conversationId) return;
121
- const shown = request.kind === "open" || serverHasIt ? conversationId : null;
122
- rememberWorkspaceConversation(surfaceKey, shown);
123
- if (shown) releaseWorkspaceConversationId(surfaceKey);
124
- const search = addressWithConversation(addressParam, window.location.search, shown);
130
+ if (!addressRead || !link || link === appliedLink.current) return;
131
+ appliedLink.current = link;
132
+ const linked = requestFromLink(link);
133
+ if (!linked) return;
134
+ if (linked.kind === "open" && linked.conversationId === conversationId) return;
135
+ setConversationId(null);
136
+ setFailure(null);
137
+ setRequest((current) => ({ ...linked, nonce: current.nonce + 1 }));
138
+ }, [addressRead, link, conversationId]);
139
+ useEffect(() => {
140
+ if (!hasAddress || !addressRead) return;
141
+ if (!conversationId && addressOpen) return;
142
+ const shown = conversationId && (request.kind === "open" || serverHasIt) ? conversationId : null;
143
+ if (conversationId) {
144
+ rememberWorkspaceConversation(surfaceKey, shown);
145
+ if (shown) releaseWorkspaceConversationId(surfaceKey);
146
+ }
147
+ const search = sideChatAddress(window.location.search, { conversationId: shown, open: addressOpen, hostedElsewhere });
125
148
  if (search === window.location.search) return;
149
+ onAddressWrite.current?.(search);
126
150
  replaceAddressWithoutNavigating(`${window.location.pathname}${search}${window.location.hash}`);
127
- }, [addressParam, addressRead, conversationId, request.kind, serverHasIt, surfaceKey]);
151
+ }, [hasAddress, addressRead, addressOpen, hostedElsewhere, conversationId, request.kind, serverHasIt, surfaceKey]);
128
152
  useEffect(() => {
129
153
  if (!enabled || waitingForOrganization || !addressRead) return;
130
154
  const key = `${surfaceKey}#${request.kind}#${request.nonce}`;
@@ -133,7 +157,7 @@ function useCanvasWorkspaceConversation(surfaceKey, input) {
133
157
  const stale = () => handled.current !== key;
134
158
  if (request.kind === "new" || request.kind === "agent") {
135
159
  const runtime = ownSurface ? { runtime: { surfaceName: null } } : {};
136
- const reserved = addressParam !== null ? reserveWorkspaceConversationId(surfaceKey, request.nonce > 0) : void 0;
160
+ const reserved = hasAddress ? reserveWorkspaceConversationId(surfaceKey, request.nonce > 0) : void 0;
137
161
  const reservation = reserved ? { conversationId: reserved } : {};
138
162
  const launch = request.kind === "agent" ? launchAgent(request.agentId, { surfaceKey, sourceFeature: "chat", ...reservation, ...runtime }) : launchMandate(request.mandateKey ?? DEFAULT_NEW_CHAT_MANDATE_KEY, {
139
163
  surfaceKey,
@@ -215,8 +239,6 @@ function useCanvasWorkspaceConversation(surfaceKey, input) {
215
239
  return { conversation, conversationId, startNew, openExisting, startWith };
216
240
  }
217
241
  export {
218
- addressWithConversation,
219
- conversationInAddress,
220
242
  recallWorkspaceConversation,
221
243
  releaseWorkspaceConversationId,
222
244
  rememberWorkspaceConversation,
@@ -28706,6 +28706,15 @@ export type ChatDatabase = {
28706
28706
  };
28707
28707
  Returns: Json;
28708
28708
  };
28709
+ _knob_steward_refusal: {
28710
+ Args: {
28711
+ p_organization_id: string;
28712
+ p_scope_id: string;
28713
+ p_scope_kind: string;
28714
+ p_uid: string;
28715
+ };
28716
+ Returns: Json;
28717
+ };
28709
28718
  _lifecycle_partition_guard: {
28710
28719
  Args: {
28711
28720
  p_partition: string;
@@ -43832,18 +43841,6 @@ export type ChatDatabase = {
43832
43841
  };
43833
43842
  Returns: Json;
43834
43843
  };
43835
- mnd_run_history: {
43836
- Args: {
43837
- p_limit?: number;
43838
- p_mandate_key: string;
43839
- p_offset?: number;
43840
- p_org_id?: string;
43841
- p_status?: string;
43842
- p_user_id?: string;
43843
- p_view?: string;
43844
- };
43845
- Returns: Json;
43846
- };
43847
43844
  move_file: {
43848
43845
  Args: {
43849
43846
  p_file_id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/chat",
3
- "version": "0.4.15",
3
+ "version": "0.4.16",
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 per-module subpaths @ai-matrx/chat/<domain>/<module> over a host contract (/host, /host/react) whose only required value is a db; plus the kernel compatibility entries (., /external-store, /matrx, /react, /tools, styles.css, tokens.css) until P28.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -228,24 +228,24 @@
228
228
  "zod": "^4.6.5",
229
229
  "@ai-matrx/agents": "latest",
230
230
  "@ai-matrx/alchemy": "latest",
231
- "@ai-matrx/associations": "latest",
232
231
  "@ai-matrx/browser-audio": "latest",
233
232
  "@ai-matrx/canvas": "latest",
234
233
  "@ai-matrx/content-ir": "latest",
234
+ "@ai-matrx/content-ir-react": "latest",
235
+ "@ai-matrx/associations": "latest",
235
236
  "@ai-matrx/data": "latest",
236
- "@ai-matrx/design-system": "latest",
237
- "@ai-matrx/diff": "latest",
238
- "@ai-matrx/icons": "latest",
239
237
  "@ai-matrx/kit": "latest",
238
+ "@ai-matrx/design-system": "latest",
240
239
  "@ai-matrx/media": "latest",
240
+ "@ai-matrx/icons": "latest",
241
241
  "@ai-matrx/print": "latest",
242
242
  "@ai-matrx/realtime": "latest",
243
+ "@ai-matrx/records": "latest",
243
244
  "@ai-matrx/records-ui": "latest",
244
245
  "@ai-matrx/rich-content": "latest",
245
- "@ai-matrx/rich-editor": "latest",
246
+ "@ai-matrx/diff": "latest",
246
247
  "@ai-matrx/tap-target": "latest",
247
- "@ai-matrx/records": "latest",
248
- "@ai-matrx/content-ir-react": "latest"
248
+ "@ai-matrx/rich-editor": "latest"
249
249
  },
250
250
  "devDependencies": {
251
251
  "@arethetypeswrong/cli": "^0.18.5",