diff --git a/.agents/docs/notifications.md b/.agents/docs/notifications.md
new file mode 100644
index 0000000..1a2e70a
--- /dev/null
+++ b/.agents/docs/notifications.md
@@ -0,0 +1,27 @@
+# Operation notifications
+
+Room actions, timeline copy/actions, send results, settings and project binding use the Host `ctx.notifications.show()` service. Source icon/name,
+card interactions, queue, expiry and suppression rules belong to Host.
+Declare `notifications` in injection requirements. Field validation and persistent
+business state stay beside the relevant control or object. Do not introduce a
+custom Toast, page-wide operation error, or raw exception message.
+
+The [Host guide](https://github.com/cordisx/cordisx/blob/3cfe370eb7abf33e16686fbd82659cd441247fbd/.agents/docs/notifications.md)
+owns usage and interaction guidance; the
+[Protocol contract](https://github.com/cordisx/cordisx-protocol/blob/f46dd21e15a949a26f05f89bf11dea339fc60c02/.agents/docs/notifications-v1.md)
+owns the public API. Source merge and package publication are separate stages.
+
+## Development dependencies
+
+`package.json` and `package-lock.json` pin formally merged Host and Protocol
+commits. Install through the repository's normal dependency flow:
+
+```sh
+npm ci
+npm run check
+```
+
+Keep the shared Protocol override so public branded types resolve to one copy.
+A missing notification service requires a supported Host upgrade; do not recreate
+notification UI as an older-Host fallback. The former local candidate SDK archive
+is no longer an installation prerequisite.
diff --git a/.gitignore b/.gitignore
index d2e8361..c8094fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@ dist/
coverage/
.DS_Store
cordisx.config.local.json
+
+.cache/sdk/
diff --git a/AGENTS.md b/AGENTS.md
index 66e62dd..8fbd1f0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,3 +13,16 @@
Do not integrate through private Host/native DOM or a standalone replacement page.
- Read `.agents/rules/README.md` before changing this repository.
- Read the organization [CSS ownership and maintenance rule](https://github.com/cordisx/cordisxmono/blob/main/.agents/rules/css.md) before changing CSS, stylesheet-generating code, or a style-bearing DOM contract.
+
+## Operation notifications
+
+Use the public `ctx.notifications.show()` service for operation feedback and
+require `notifications` in plugin injection. Do not create a custom Toast,
+manually positioned alert, or page-wide success/error paragraph. Keep field
+validation and durable business state beside the relevant object. Use stable
+semantic `kind` values, localized safe text, and notification rules owned by Host;
+never expose raw exceptions or notify on every polling attempt.
+See the [Host notification guide](https://github.com/cordisx/cordisx/blob/3cfe370eb7abf33e16686fbd82659cd441247fbd/.agents/docs/notifications.md)
+for the interaction contract and older-Host capability boundary.
+
+Dependency setup: [notification migration](./.agents/docs/notifications.md).
diff --git a/README.md b/README.md
index 8696efc..921ff8c 100644
--- a/README.md
+++ b/README.md
@@ -218,3 +218,7 @@ validation; that does not make them a merged or released dependency.
## License
[MIT](LICENSE)
+
+## Notification feedback
+
+See [operation notifications and development dependencies](./.agents/docs/notifications.md).
diff --git a/package-lock.json b/package-lock.json
index b3b5203..90b8a1b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,7 @@
},
"devDependencies": {
"@cordisx/eslint-config": "github:cordisx/cordisxmono#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc",
- "@cordisx/protocol": "github:cordisx/cordisx-protocol#a39c5d1f61999a69043180d6b7e7bbbee3072c09",
+ "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02",
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/schemastery": "3.18.1",
"@oneworks/avatar": "1.0.0-rc.9",
@@ -25,7 +25,7 @@
"@vitejs/plugin-react": "6.1.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
- "cordisx": "github:cordisx/cordisx#e0a7d7b6fbd653dcf55b4105d3d193e49443826f",
+ "cordisx": "github:cordisx/cordisx#3cfe370eb7abf33e16686fbd82659cd441247fbd",
"dprint": "0.57.1",
"esbuild": "^0.28.2",
"eslint": "9.39.4",
@@ -143,16 +143,6 @@
"@keyv/serialize": "^1.1.1"
}
},
- "node_modules/@cordisx/channel": {
- "version": "0.1.0",
- "resolved": "git+ssh://git@github.com/cordisx/plugin-channel.git#4cee12e3a92eeed557bc9de8cc4792710918327a",
- "integrity": "sha512-WqKvGxy72WwAUdwoLIHTBCG5/MAwVLKh2VmGK3tdSB+EMSUmuR+lAhxfdt9dYQRQL1Mh3t/w8SdYqps4mO0FkQ==",
- "dev": true,
- "license": "AGPL-3.0-or-later",
- "engines": {
- "node": ">=22.19"
- }
- },
"node_modules/@cordisx/eslint-config": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/cordisx/cordisxmono.git#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc",
@@ -165,20 +155,10 @@
"eslint": "^9.39.4 || ^10.0.0"
}
},
- "node_modules/@cordisx/plugin-cli-proxy-api": {
- "version": "0.1.0",
- "resolved": "git+ssh://git@github.com/cordisx/plugin-cli-proxy-api.git#1428ee205aab31df2779398cc8491879b303d1d0",
- "integrity": "sha512-EyIa/JSSHhbd9buCUd2gnI/Cdo47GYmJC+3FelfTNrA/krZSJN8TFE1c09yhySUtImtUliGv7ICa0XG+Ix/Kww==",
- "dev": true,
- "license": "AGPL-3.0-or-later",
- "engines": {
- "node": ">=22.19"
- }
- },
"node_modules/@cordisx/protocol": {
"version": "0.1.0-alpha.0",
- "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#a39c5d1f61999a69043180d6b7e7bbbee3072c09",
- "integrity": "sha512-42+ErrJ8cJvXj0LB9Qt3+kAycATyXT5ifsM3HfjtwYDtSBTcSVZmhF/Tod5fFzut2jM7IvWvyr5SF1jxXKpnLQ==",
+ "resolved": "git+ssh://git@github.com/cordisx/cordisx-protocol.git#f46dd21e15a949a26f05f89bf11dea339fc60c02",
+ "integrity": "sha512-o/YzvFs5gkrYsK0vijubLN/go2/oL04sixPz/Ks7N0eMLEHUL7qxD56Yf1ltW5dN9qfBIpgkHuiXgkSgOjj1bw==",
"dev": true,
"license": "AGPL-3.0-or-later",
"engines": {
@@ -2798,10 +2778,12 @@
"node_modules/cordisx": {
"name": "cordisx-monorepo",
"version": "0.1.0-beta.2",
- "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#e0a7d7b6fbd653dcf55b4105d3d193e49443826f",
- "integrity": "sha512-YTPu42VaCvABOAmgstRlBBmsn8YzNDw5KRa4PtfVXjWWu3mr3qUxVVEoBDTFr+qgh+PrHz74SsHSlCj7Hy5pnQ==",
+ "resolved": "git+ssh://git@github.com/cordisx/cordisx.git#3cfe370eb7abf33e16686fbd82659cd441247fbd",
+ "integrity": "sha512-k6IhNsliOoWBDU0PuaQJUWO/llUdJAlrcdV9ErjyjSK+kYK0MJzO9UWWSnTc3bJGwLJ/Osv1cNo+tGV7BPwhcA==",
"bundleDependencies": [
- "@cordisx/schemastery-ui"
+ "@cordisx/schemastery-ui",
+ "@cordisx/channel",
+ "@cordisx/plugin-cli-proxy-api"
],
"dev": true,
"license": "AGPL-3.0-or-later",
@@ -2811,7 +2793,7 @@
"dependencies": {
"@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a",
"@cordisx/plugin-cli-proxy-api": "github:cordisx/plugin-cli-proxy-api#1428ee205aab31df2779398cc8491879b303d1d0",
- "@cordisx/protocol": "github:cordisx/cordisx-protocol#a39c5d1f61999a69043180d6b7e7bbbee3072c09",
+ "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02",
"@cordisx/schemastery-ui": "0.1.0-beta.2",
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/schemastery": "^3.18.1",
@@ -2882,14 +2864,16 @@
"name": "cordisx",
"version": "0.1.0-beta.2",
"bundleDependencies": [
- "@cordisx/schemastery-ui"
+ "@cordisx/schemastery-ui",
+ "@cordisx/channel",
+ "@cordisx/plugin-cli-proxy-api"
],
"extraneous": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
- "@cordisx/channel": "github:cordisx/plugin-channel#4cee12e3a92eeed557bc9de8cc4792710918327a",
- "@cordisx/plugin-cli-proxy-api": "github:cordisx/plugin-cli-proxy-api#1428ee205aab31df2779398cc8491879b303d1d0",
- "@cordisx/protocol": "github:cordisx/cordisx-protocol#a39c5d1f61999a69043180d6b7e7bbbee3072c09",
+ "@cordisx/channel": "0.1.0",
+ "@cordisx/plugin-cli-proxy-api": "0.1.0",
+ "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02",
"@cordisx/schemastery-ui": "0.1.0-beta.2",
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/schemastery": "^3.18.1",
@@ -2930,7 +2914,6 @@
},
"devDependencies": {
"@cordisx/channel-runtime": "file:../channel-runtime",
- "@cordisx/protocol": "github:cordisx/cordisx-protocol#a39c5d1f61999a69043180d6b7e7bbbee3072c09",
"@types/node": "^22.18.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
@@ -7783,27 +7766,6 @@
"dev": true,
"license": "0BSD"
},
- "node_modules/tsx": {
- "version": "4.23.13",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
- "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "esbuild": "~0.28.0"
- },
- "bin": {
- "tsx": "dist/cli.mjs"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- }
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index 3ef0fd3..598ed48 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
},
"devDependencies": {
"@cordisx/eslint-config": "github:cordisx/cordisxmono#c63c2e8c2ba7e11502934a52ad2ce3734e804cdc",
- "@cordisx/protocol": "github:cordisx/cordisx-protocol#a39c5d1f61999a69043180d6b7e7bbbee3072c09",
+ "@cordisx/protocol": "github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02",
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/schemastery": "3.18.1",
"@oneworks/avatar": "1.0.0-rc.9",
@@ -47,7 +47,7 @@
"@vitejs/plugin-react": "6.1.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
- "cordisx": "github:cordisx/cordisx#e0a7d7b6fbd653dcf55b4105d3d193e49443826f",
+ "cordisx": "github:cordisx/cordisx#3cfe370eb7abf33e16686fbd82659cd441247fbd",
"dprint": "0.57.1",
"esbuild": "^0.28.2",
"eslint": "9.39.4",
@@ -64,5 +64,8 @@
},
"bin": {
"cordisx-chatroom": "./dist/agent-tools/cli/chatroom.mjs"
+ },
+ "overrides": {
+ "@cordisx/protocol": "$@cordisx/protocol"
}
}
diff --git a/src/chatroom-composer.tsx b/src/chatroom-composer.tsx
index 6cee7c2..c1e32d8 100644
--- a/src/chatroom-composer.tsx
+++ b/src/chatroom-composer.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from 'cordisx/react';
import type { CordisXReactPageProps } from 'cordisx/contracts';
import {
@@ -117,7 +118,7 @@ export function ChatroomComposer(
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [aborted, setAborted] = useState(signal.aborted);
- const [notice, setNotice] = useState();
+ const notifications = useNotifications();
const [error, setError] = useState();
const [query, setQuery] = useState();
const [activeIndex, setActiveIndex] = useState(0);
@@ -231,7 +232,6 @@ export function ChatroomComposer(
setSending(true);
setQuery(undefined);
setError(undefined);
- setNotice(undefined);
try {
if (firstMessage !== undefined) {
const result = await firstMessage(submittedDraft);
@@ -252,14 +252,16 @@ export function ChatroomComposer(
if (!mounted.current || signal.aborted) return;
const result = source.pageComposerCompletion(completion);
if (result.status !== 'accepted') {
- setError(t('composer.send-failed'));
+ notifications.show({ kind: 'message.send-failed', type: 'error', message: t('composer.send-failed') });
return;
}
}
setDraft(current => current === submittedDraft ? '' : current);
- setNotice(t('composer.sent'));
+ notifications.show({ kind: 'message.sent', type: 'success', message: t('composer.sent') });
} catch {
- if (mounted.current && !signal.aborted) setError(t('composer.send-failed'));
+ if (mounted.current && !signal.aborted) {
+ notifications.show({ kind: 'message.send-failed', type: 'error', message: t('composer.send-failed') });
+ }
} finally {
busy.current = false;
if (mounted.current && !signal.aborted) setSending(false);
@@ -355,7 +357,6 @@ export function ChatroomComposer(
onValueChange={value => {
draftRef.current = value;
setDraft(value);
- setNotice(undefined);
setError(undefined);
updateQuery(value, input.current?.getSelection());
}}
@@ -414,7 +415,7 @@ export function ChatroomComposer(
{t(shortcutPolicy === 'enter' ? 'composer.shortcut.enter' : 'composer.shortcut.mod-enter')}
- {unavailable ? t('composer.unavailable') : sending ? t('composer.sending') : notice}
+ {unavailable ? t('composer.unavailable') : sending ? t('composer.sending') : undefined}
{error !== undefined && {error}
}
diff --git a/src/chatroom-entity-settings.tsx b/src/chatroom-entity-settings.tsx
index 53f2599..7916c17 100644
--- a/src/chatroom-entity-settings.tsx
+++ b/src/chatroom-entity-settings.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { useEffect, useRef, useState } from 'cordisx/react';
import { Button, Icon } from 'cordisx/ui';
import type { CordisXReactPageProps } from 'cordisx/contracts';
@@ -12,13 +13,12 @@ export function ChatroomEntitySettings({ room, participantId, details, t }: {
}) {
const [available, setAvailable] = useState(false);
const [busy, setBusy] = useState(false);
- const [failed, setFailed] = useState(false);
+ const notifications = useNotifications();
const pending = useRef(false);
const member = room.memberships.find(candidate => candidate.participantId === participantId);
useEffect(() => {
let cancelled = false;
setAvailable(false);
- setFailed(false);
void details.entitySettingsAvailable(room, participantId).then(value => {
if (!cancelled) setAvailable(value);
}).catch(() => {});
@@ -37,11 +37,16 @@ export function ChatroomEntitySettings({ room, participantId, details, t }: {
if (pending.current) return;
pending.current = true;
setBusy(true);
- setFailed(false);
try {
- if (!await details.openEntitySettings(room, participantId)) setFailed(true);
+ if (!await details.openEntitySettings(room, participantId)) {
+ notifications.show({
+ kind: 'settings.open-failed',
+ type: 'error',
+ message: t('identity.settings.failed'),
+ });
+ }
} catch {
- setFailed(true);
+ notifications.show({ kind: 'settings.open-failed', type: 'error', message: t('identity.settings.failed') });
} finally {
pending.current = false;
setBusy(false);
@@ -50,7 +55,6 @@ export function ChatroomEntitySettings({ room, participantId, details, t }: {
>
- {failed && {t('identity.settings.failed')}
}
);
}
diff --git a/src/chatroom-member-details.tsx b/src/chatroom-member-details.tsx
index b424add..38273c9 100644
--- a/src/chatroom-member-details.tsx
+++ b/src/chatroom-member-details.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { ChatroomEntitySettings } from './chatroom-entity-settings.js';
import { useEffect, useState } from 'cordisx/react';
import { Button, MarkdownViewer } from 'cordisx/ui';
@@ -20,7 +21,7 @@ export function ChatroomMemberDetails({ snapshot, participantId, details, t }: {
const [entity, setEntity] = useState();
const [loading, setLoading] = useState(true);
const [opening, setOpening] = useState();
- const [error, setError] = useState(false);
+ const notifications = useNotifications();
const room = snapshot.room;
const member = room?.memberships.find(candidate => candidate.participantId === participantId);
useEffect(() => {
@@ -54,11 +55,12 @@ export function ChatroomMemberDetails({ snapshot, participantId, details, t }: {
const open = async (sessionId: typeof sessions[number]['sessionId']) => {
if (opening !== undefined) return;
setOpening(sessionId);
- setError(false);
try {
- if (!await details.openSession(room, participantId, sessionId)) setError(true);
+ if (!await details.openSession(room, participantId, sessionId)) {
+ notifications.show({ kind: 'session.open-failed', type: 'error', message: t('identity.session.open-failed') });
+ }
} catch {
- setError(true);
+ notifications.show({ kind: 'session.open-failed', type: 'error', message: t('identity.session.open-failed') });
} finally {
setOpening(undefined);
}
@@ -102,7 +104,6 @@ export function ChatroomMemberDetails({ snapshot, participantId, details, t }: {
))}
)}
- {error && {t('identity.session.open-failed')}
}
{unassignedTasks.length > 0 && (
diff --git a/src/chatroom-page-loader.tsx b/src/chatroom-page-loader.tsx
index da97cf5..354d8d9 100644
--- a/src/chatroom-page-loader.tsx
+++ b/src/chatroom-page-loader.tsx
@@ -1,5 +1,6 @@
import { type ComponentType, defineReactPage, lazy, Suspense } from 'cordisx/react';
-import type { CordisXReactPageProps } from 'cordisx/contracts';
+import { NotificationsContext } from './notifications.js';
+import type { CordisXReactPageProps, NotificationsV1 } from 'cordisx/contracts';
import type { ChatroomPageSource } from './chatroom-page-source.js';
import type { ChatroomPageDetails } from './chatroom-page-details.js';
@@ -25,6 +26,7 @@ export const createChatroomPageModuleLoader = (
/** Registration stays light; the page graph starts loading only on an actual mount. */
export function createLazyChatroomPage(
source: ChatroomPageSource,
+ notifications: NotificationsV1,
imageCache: ChatroomSidebarImageCache,
details?: ChatroomPageDetails,
) {
@@ -35,8 +37,10 @@ export function createLazyChatroomPage(
default: (await loadChatroomPageModule()).ChatroomPage,
}));
return defineReactPage(props => (
-
-
-
+
+
+
+
+
));
}
diff --git a/src/chatroom-page.tsx b/src/chatroom-page.tsx
index 6971b42..979aeb7 100644
--- a/src/chatroom-page.tsx
+++ b/src/chatroom-page.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { ChatroomLeaderPicker } from './chatroom-new-room.js';
import {
type CSSProperties,
@@ -60,13 +61,13 @@ export function ChatroomPage(
) {
const [inspector, setInspector] = useState();
const [selectedLeader, setSelectedLeader] = useState();
- const [roomOpenFailed, setRoomOpenFailed] = useState(false);
+
const root = useRef(null);
const panel = useRef(null);
const { width, narrow, separatorProps } = useChatroomInspector(root, inspector !== undefined, props.signal);
const [memberSearch, setMemberSearch] = useState('');
const [mentionRequest, setMentionRequest] = useState<{ participantId: string; sequence: number; }>();
- const [actionError, setActionError] = useState(false);
+ const notifications = useNotifications();
const [busyAction, setBusyAction] = useState();
const actionPending = useRef(false);
const actionGeneration = useRef(0);
@@ -95,12 +96,13 @@ export function ChatroomPage(
if (action.disabled || actionPending.current || props.signal.aborted) return;
actionPending.current = true;
setBusyAction(action.id);
- setActionError(false);
const generation = actionGeneration.current;
try {
await action.run();
} catch {
- if (generation === actionGeneration.current && !props.signal.aborted) setActionError(true);
+ if (generation === actionGeneration.current && !props.signal.aborted) {
+ notifications.show({ kind: 'room.action-failed', type: 'error', message: props.t('page.action.failed') });
+ }
} finally {
if (generation === actionGeneration.current && !props.signal.aborted) {
actionPending.current = false;
@@ -113,7 +115,6 @@ export function ChatroomPage(
setInspector(undefined);
setMemberSearch('');
setMentionRequest(undefined);
- setActionError(false);
setBusyAction(undefined);
actionPending.current = false;
actionGeneration.current += 1;
@@ -280,7 +281,6 @@ export function ChatroomPage(
- {actionError &&
{props.t('page.action.failed')}
}
{roomId === undefined && details !== undefined
? (
)}
- {roomOpenFailed &&
{props.t('task.start.open-failed')}
}
{
- setRoomOpenFailed(false);
const result = await details.startRoom(text, selectedLeader, props.signal);
if (props.signal.aborted) return { status: 'unavailable', message: props.t('composer.unavailable') };
if (result.status !== 'accepted') {
@@ -336,7 +334,13 @@ export function ChatroomPage(
try {
await props.navigation.navigate({ id: 'room', params: { roomId: result.roomId } });
} catch {
- if (!props.signal.aborted) setRoomOpenFailed(true);
+ if (!props.signal.aborted) {
+ notifications.show({
+ kind: 'room.open-failed',
+ type: 'error',
+ message: props.t('task.start.open-failed'),
+ });
+ }
}
return { status: 'accepted' };
}
diff --git a/src/chatroom-room-actions.tsx b/src/chatroom-room-actions.tsx
index 063cd7e..42f35a4 100644
--- a/src/chatroom-room-actions.tsx
+++ b/src/chatroom-room-actions.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { type KeyboardEvent, useEffect, useRef, useState } from 'cordisx/react';
import { Button } from 'cordisx/ui';
import type { CordisXReactPageProps } from 'cordisx/contracts';
@@ -22,7 +23,7 @@ export function ChatroomRoomActions({ room, details, t, onDeleted }: {
const dialog = useRef(null);
const [open, setOpen] = useState(false);
const [confirmation, setConfirmation] = useState();
- const [feedback, setFeedback] = useState<{ text: string; failed: boolean; }>();
+ const notifications = useNotifications();
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const [clipboardAvailable, setClipboardAvailable] = useState(false);
@@ -59,7 +60,6 @@ export function ChatroomRoomActions({ room, details, t, onDeleted }: {
if (pending.current || action.disabled.value) return;
pending.current = true;
setBusy(true);
- setFeedback(undefined);
try {
if (action.kind === 'command') {
await details.executeRoomAction(room.id, action.id);
@@ -70,11 +70,15 @@ export function ChatroomRoomActions({ room, details, t, onDeleted }: {
if (text === undefined) throw new Error('Room link unavailable');
await clipboard.writeText(text);
}
- setFeedback({ text: display(action.feedback.success, t), failed: false });
+ notifications.show({
+ kind: 'room.action-completed',
+ type: 'success',
+ message: display(action.feedback.success, t),
+ });
close();
if (action.id === 'delete') await onDeleted();
} catch {
- setFeedback({ text: display(action.feedback.failure, t), failed: true });
+ notifications.show({ kind: 'room.action-failed', type: 'error', message: display(action.feedback.failure, t) });
} finally {
pending.current = false;
setBusy(false);
@@ -167,7 +171,6 @@ export function ChatroomRoomActions({ room, details, t, onDeleted }: {
)}
- {feedback &&
{feedback.text}
}
);
}
diff --git a/src/chatroom-room-settings.tsx b/src/chatroom-room-settings.tsx
index 9e250fc..9713ac7 100644
--- a/src/chatroom-room-settings.tsx
+++ b/src/chatroom-room-settings.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { useRef, useState } from 'cordisx/react';
import { Button } from 'cordisx/ui';
import type { CordisXReactPageProps } from 'cordisx/contracts';
@@ -12,11 +13,12 @@ export function ChatroomRoomSettings({ roomId, details, t, onSaved }: {
readonly t: CordisXReactPageProps['t'];
readonly onSaved?: () => void;
}) {
+ const notifications = useNotifications();
const [original, setOriginal] = useState(() => details.profile(roomId));
const [name, setName] = useState(original?.room.title ?? '');
const [description, setDescription] = useState(original?.room.description ?? '');
const [busy, setBusy] = useState(false);
- const [feedback, setFeedback] = useState<'saved' | 'failed' | 'name-invalid' | 'description-invalid'>();
+ const [feedback, setFeedback] = useState<'name-invalid' | 'description-invalid'>();
const saving = useRef(false);
const dirty = name !== (original?.room.title ?? '') || description !== (original?.room.description ?? '');
const save = async () => {
@@ -35,10 +37,10 @@ export function ChatroomRoomSettings({ roomId, details, t, onSaved }: {
try {
await details.saveProfile(roomId, original.revision, name, description);
setOriginal(details.profile(roomId));
- setFeedback('saved');
+ notifications.show({ kind: 'room.settings', type: 'success', message: t('room.settings.saved') });
onSaved?.();
} catch {
- setFeedback('failed');
+ notifications.show({ kind: 'room.settings', type: 'error', message: t('room.settings.failed') });
} finally {
saving.current = false;
setBusy(false);
@@ -74,7 +76,7 @@ export function ChatroomRoomSettings({ roomId, details, t, onSaved }: {
{feedback === undefined
? null
- : {t(`room.settings.${feedback}`)}
}
+ : {t(`room.settings.${feedback}`)}
}
);
}
diff --git a/src/chatroom-timeline-entries.tsx b/src/chatroom-timeline-entries.tsx
index 436288c..42a3da2 100644
--- a/src/chatroom-timeline-entries.tsx
+++ b/src/chatroom-timeline-entries.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { type KeyboardEvent, type MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'cordisx/react';
import { Button, Icon } from 'cordisx/ui';
import type { CordisXReactPageProps } from 'cordisx/contracts';
@@ -380,16 +381,19 @@ export function ApprovalItem(
}
}, [item.state]);
const [busy, setBusy] = useState(false);
- const [error, setError] = useState(false);
+ const notifications = useNotifications();
const decide = async (decision: 'approved' | 'denied') => {
if (pending.current || item.state !== 'pending') return;
pending.current = true;
setBusy(true);
- setError(false);
try {
- if (!await source.decideApproval(roomId, item.itemId, decision) && mounted.current) setError(true);
+ if (!await source.decideApproval(roomId, item.itemId, decision) && mounted.current) {
+ notifications.show({ kind: 'approval.decision-failed', type: 'error', message: t('approval.decision.failed') });
+ }
} catch {
- if (mounted.current) setError(true);
+ if (mounted.current) {
+ notifications.show({ kind: 'approval.decision-failed', type: 'error', message: t('approval.decision.failed') });
+ }
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
@@ -499,7 +503,6 @@ export function ApprovalItem(
)}
)}
- {error && {t('approval.decision.failed')}
}
);
}
diff --git a/src/chatroom-timeline.tsx b/src/chatroom-timeline.tsx
index fdbcf7d..3c2d23f 100644
--- a/src/chatroom-timeline.tsx
+++ b/src/chatroom-timeline.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { useEffect, useLayoutEffect, useRef, useState } from 'cordisx/react';
import { EmptyState } from 'cordisx/ui';
import type { CordisXReactPageProps } from 'cordisx/contracts';
@@ -52,8 +53,7 @@ export function ChatroomTimeline(
const region = useRef(null);
const menuElement = useRef(null);
const [actions, setActions] = useState();
- const [copyStatus, setCopyStatus] = useState<'copied' | 'copy-failed'>();
- const [actionFailed, setActionFailed] = useState(false);
+ const notifications = useNotifications();
const [runningActions, setRunningActions] = useState>(() => new Set());
const actionPending = useRef(new Set());
const copying = useRef(false);
@@ -105,12 +105,13 @@ export function ChatroomTimeline(
if (actionPending.current.has(key)) return;
actionPending.current.add(key);
setRunningActions(new Set(actionPending.current));
- setActionFailed(false);
setActions(undefined);
try {
await source.executeMessageAction(roomId, itemId, actionId);
} catch {
- if (mounted.current) setActionFailed(true);
+ if (mounted.current) {
+ notifications.show({ kind: 'timeline.action-failed', type: 'error', message: t('timeline.action-failed') });
+ }
} finally {
actionPending.current.delete(key);
if (mounted.current) setRunningActions(new Set(actionPending.current));
@@ -138,17 +139,20 @@ export function ChatroomTimeline(
const write = copyText
?? (typeof clipboard?.writeText === 'function' ? (value: string) => clipboard.writeText(value) : undefined);
if (write === undefined) {
- setCopyStatus('copy-failed');
+ notifications.show({ kind: 'timeline.copy', type: 'error', message: t('timeline.copy-failed') });
return;
}
copying.current = true;
- setCopyStatus(undefined);
closeActions(true);
try {
await write(text);
- if (mounted.current) setCopyStatus('copied');
+ if (mounted.current) {
+ notifications.show({ kind: 'timeline.copy', type: 'success', message: t('timeline.copied') });
+ }
} catch {
- if (mounted.current) setCopyStatus('copy-failed');
+ if (mounted.current) {
+ notifications.show({ kind: 'timeline.copy', type: 'error', message: t('timeline.copy-failed') });
+ }
} finally {
copying.current = false;
}
@@ -291,10 +295,6 @@ export function ChatroomTimeline(
)}
)}
- {copyStatus !== undefined && (
- {t(`timeline.${copyStatus}`)}
- )}
- {actionFailed && {t('timeline.action-failed')}
}
({
} as const);
export const inject = [
+ 'notifications',
'i18n',
'commands',
'pages',
@@ -481,6 +482,7 @@ export async function apply(ctx: Context, config: unknown = {}): Promise {
const { createLazyChatroomPage } = await import('./chatroom-page-loader.js');
const pageMount = createLazyChatroomPage(
pageSource,
+ ctx.notifications,
product.sidebarImages,
new ChatroomPageDetails({
entitySettings: ctx.entitySettingsNavigation,
diff --git a/src/entity-project-binding.tsx b/src/entity-project-binding.tsx
index 4efd307..5b3e3fe 100644
--- a/src/entity-project-binding.tsx
+++ b/src/entity-project-binding.tsx
@@ -1,3 +1,4 @@
+import { useNotifications } from './notifications.js';
import { useEffect, useRef, useState } from 'cordisx/react';
import { Button } from 'cordisx/ui';
import type { AgentDefinitionIdentity } from '@cordisx/protocol/agents/v1';
@@ -25,6 +26,7 @@ export function EntityProjectBinding({ identity, contexts, t }: {
readonly contexts?: EntityExecutionContexts;
readonly t: Translate;
}) {
+ const notifications = useNotifications();
const [snapshot, setSnapshot] = useState();
const [projects, setProjects] = useState();
const [selected, setSelected] = useState('');
@@ -92,7 +94,7 @@ export function EntityProjectBinding({ identity, contexts, t }: {
const currentEpoch = epoch.current;
const key = JSON.stringify([identity, snapshot.revision, selected]);
if (mutation.current !== undefined && mutation.current.key !== key) {
- setFeedback(t('detail.project-conflict'));
+ notifications.show({ kind: 'project.save', type: 'error', message: t('detail.project-conflict') });
return;
}
mutation.current ??= { key, id: `entity-project.${crypto.randomUUID()}` };
@@ -110,12 +112,20 @@ export function EntityProjectBinding({ identity, contexts, t }: {
if (result.status === 'applied') {
mutation.current = undefined;
setSnapshot(result);
- setFeedback(t('detail.project-saved'));
- } else {setFeedback(
- t(result.status === 'conflict' ? 'detail.project-conflict' : 'detail.project-unavailable'),
- );}
+ notifications.show({ kind: 'project.save', type: 'success', message: t('detail.project-saved') });
+ } else {notifications.show({
+ kind: 'project.save',
+ type: 'error',
+ message: t(result.status === 'conflict' ? 'detail.project-conflict' : 'detail.project-unavailable'),
+ });}
} catch {
- if (mounted.current && epoch.current === currentEpoch) setFeedback(t('detail.project-unavailable'));
+ if (mounted.current && epoch.current === currentEpoch) {
+ notifications.show({
+ kind: 'project.save',
+ type: 'error',
+ message: t('detail.project-unavailable'),
+ });
+ }
} finally {
if (epoch.current === currentEpoch) {
pending.current = false;
@@ -127,7 +137,6 @@ export function EntityProjectBinding({ identity, contexts, t }: {
{t('detail.project-save')}
{t('detail.project-future-only')}
- {feedback && {feedback}
}
);
}
diff --git a/src/notifications.tsx b/src/notifications.tsx
new file mode 100644
index 0000000..b25c4df
--- /dev/null
+++ b/src/notifications.tsx
@@ -0,0 +1,9 @@
+import { createContext, useContext } from 'cordisx/react';
+import type { NotificationsV1 } from 'cordisx/contracts';
+
+export const NotificationsContext = createContext(undefined);
+export function useNotifications(): NotificationsV1 {
+ const service = useContext(NotificationsContext);
+ if (service === undefined) throw new Error('Chatroom requires the Host notifications service.');
+ return service;
+}
diff --git a/src/team-architecture-navigation.ts b/src/team-architecture-navigation.ts
index c01e10f..af5d5f9 100644
--- a/src/team-architecture-navigation.ts
+++ b/src/team-architecture-navigation.ts
@@ -1,3 +1,4 @@
+import type { NotificationsV1 } from 'cordisx/contracts';
import {
CORDISX_MANAGER_CONTENT_NAVIGATION_SCHEMA_V1,
CORDISX_MANAGER_CONTENT_NAVIGATION_SCHEMA_V2,
@@ -590,6 +591,7 @@ export const TEAM_ARCHITECTURE_LOCALES: readonly CordisXLocaleCatalog context.i18n.define(catalog)));
const pageMount = createTeamArchitecturePage(
source,
+ context.notifications,
TEAM_ARCHITECTURE_DETAIL_ROUTE_IDS,
context.i18n.seat(TEAM_ARCHITECTURE_LOCALE_NAMESPACE),
);
diff --git a/src/team-architecture-page.tsx b/src/team-architecture-page.tsx
index 97a0eed..e72e324 100644
--- a/src/team-architecture-page.tsx
+++ b/src/team-architecture-page.tsx
@@ -1,3 +1,5 @@
+import { NotificationsContext } from './notifications.js';
+import type { NotificationsV1 } from 'cordisx/contracts';
import { EntityProjectBinding } from './entity-project-binding.js';
import {
Fragment,
@@ -909,10 +911,13 @@ function projectEntities(snapshot: ReturnType,
) {
return defineReactPage(props => (
-
+
+
+
));
}
diff --git a/test/chatroom-composer.mjs b/test/chatroom-composer.mjs
index dcf71b2..e4e48d8 100644
--- a/test/chatroom-composer.mjs
+++ b/test/chatroom-composer.mjs
@@ -5,8 +5,11 @@ import { build } from 'esbuild';
// Exercise the production component's handlers and effect cleanup with a bounded
// hook host. This is component behavior evidence, not native renderer acceptance.
const hooks = `
+export const notifications = [];
+export function createContext(value) { return {value}; }
+export function useContext() { return { show: options => { notifications.push(options); return { dismiss() {} }; } }; }
let slots = [], cursor = 0, pending = [];
-export function reset() { slots = []; }
+export function reset() { slots = []; notifications.length = 0; }
export function begin() { cursor = 0; pending = []; }
export function finish() { for (const effect of pending) effect(); }
export function cleanup() { for (const slot of slots) slot?.cleanup?.(); }
@@ -239,7 +242,7 @@ test('only one public command executes while busy; accepted completion clears th
await settle(ui);
assert.equal(ui.input().props.value, '');
assert.equal(ui.input().props.disabled, false);
- assert.equal(ui.find(node => node.props?.role === 'status').props.children, 'composer.sent');
+ assert.equal(module.hooks.notifications.at(-1).message, 'composer.sent');
});
test('failed completion retains the draft and retry uses the same public seam', async () => {
@@ -248,7 +251,7 @@ test('failed completion retains the draft and retry uses the same public seam',
ui.submit();
await settle(ui);
assert.equal(ui.input().props.value, 'retain this');
- assert.equal(ui.find(node => node.props?.role === 'alert').props.children, 'composer.send-failed');
+ assert.equal(module.hooks.notifications.at(-1).message, 'composer.send-failed');
ui.props.pageComposer = { execute: async () => ({ status: 'accepted' }) };
ui.render();
ui.submit();
@@ -401,7 +404,7 @@ test('a Host UTF16 admission rejection keeps the complete supplementary-characte
ui.submit();
await settle(ui);
assert.equal(ui.input().props.value, draft);
- assert.equal(ui.find(node => node.props?.role === 'alert').props.children, 'composer.send-failed');
+ assert.equal(module.hooks.notifications.at(-1).message, 'composer.send-failed');
});
test('uses the public controlled MarkdownEditor and imperative selection contract', () => {
diff --git a/test/chatroom-page-behavior.mjs b/test/chatroom-page-behavior.mjs
index 8851066..54cb48d 100644
--- a/test/chatroom-page-behavior.mjs
+++ b/test/chatroom-page-behavior.mjs
@@ -238,7 +238,7 @@ test('header action prevents duplicate execution and surfaces failure without ch
reject(new Error('failed'));
await new Promise(resolve => setImmediate(resolve));
tree = render();
- assert.equal(byClass(tree, 'cx-chatroom-page__error').props.children, 'page.action.failed');
+ assert.equal(harness.notifications.at(-1).message, 'page.action.failed');
});
test('inspector resize clamps pointer/keyboard width and preserves it across detail pages and close', async () => {
@@ -405,7 +405,7 @@ test('timeline menus offer detail/mention, keyboard dismissal and write-only cop
assert.deepEqual(copies, ['**exact text**'], 'writeText is invoked synchronously within the explicit click handler');
await new Promise(resolve => setImmediate(resolve));
tree = render();
- assert.equal(byClass(tree, 'cx-chatroom-timeline__feedback').props.children, 'timeline.copied');
+ assert.equal(harness.notifications.at(-1).message, 'timeline.copied');
byClass(message(), 'cx-chatroom-message__time').props.onClick({ currentTarget: trigger });
assert.deepEqual(copies.at(-1), item.timestamp);
await new Promise(resolve => setImmediate(resolve));
@@ -446,7 +446,7 @@ test('timeline copying is honestly disabled when absent and reports browser perm
message = all(tree, node => node.props?.item === item)[0];
byClass(message.type(message.props), 'cx-chatroom-message__time').props.onClick({ currentTarget: trigger });
await new Promise(resolve => setImmediate(resolve));
- assert.equal(byClass(render(), 'cx-chatroom-timeline__feedback').props.children, 'timeline.copy-failed');
+ assert.equal(harness.notifications.at(-1).message, 'timeline.copy-failed');
});
test('message actions preserve order, current execution, disabled reasons and duplicate-click fencing', async () => {
@@ -522,7 +522,7 @@ test('message actions preserve order, current execution, disabled reasons and du
rejectFirst(new Error('command failed'));
await new Promise(resolve => setImmediate(resolve));
tree = render();
- assert.equal(all(tree, node => node.props?.role === 'alert')[0].props.children, 'timeline.action-failed');
+ assert.equal(harness.notifications.at(-1).message, 'timeline.action-failed');
direct = all(message(tree), node => node.props?.className === 'cx-chatroom-message__command');
direct[0].props.onClick({ stopPropagation() {} });
await new Promise(resolve => setImmediate(resolve));
@@ -791,7 +791,7 @@ test('new Room uses the normal composer with an optional Leader selection, never
assert.deepEqual(await all(tree, node => node.type === 'Composer')[0].props.firstMessage('accepted'), {
status: 'accepted',
});
- assert.equal(all(render(), node => node.props?.role === 'status')[0].props.children, 'task.start.open-failed');
+ assert.equal(harness.notifications.at(-1).message, 'task.start.open-failed');
});
test('Room settings rejects backend-invalid names, prevents duplicate save and closes only on success', async () => {
diff --git a/test/entity-project-binding.mjs b/test/entity-project-binding.mjs
index 149758b..1a400b6 100644
--- a/test/entity-project-binding.mjs
+++ b/test/entity-project-binding.mjs
@@ -47,7 +47,7 @@ test('project binding retry retains the mutation and never starts a Session', as
assert.equal(h.requests.length, 2);
assert.deepEqual(h.requests[0], h.requests[1]);
assert.deepEqual(h.requests[0].binding, { kind: 'project', projectId: 'actual-project' });
- assert.equal(all(h.render(), node => node.props?.role === 'status')[0].props.children, 'detail.project-saved');
+ assert.equal(h.harness.notifications.at(-1).message, 'detail.project-saved');
h.harness.unmount();
});
diff --git a/test/entity-templates.mjs b/test/entity-templates.mjs
index f4d0f46..88ab05a 100644
--- a/test/entity-templates.mjs
+++ b/test/entity-templates.mjs
@@ -199,11 +199,11 @@ test('package pins the exact Protocol and Host revisions with exact manifest byt
const packageJson = JSON.parse(readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'));
assert.equal(
packageJson.devDependencies['@cordisx/protocol'],
- 'github:cordisx/cordisx-protocol#a39c5d1f61999a69043180d6b7e7bbbee3072c09',
+ 'github:cordisx/cordisx-protocol#f46dd21e15a949a26f05f89bf11dea339fc60c02',
);
assert.equal(
packageJson.devDependencies.cordisx,
- 'github:cordisx/cordisx#e0a7d7b6fbd653dcf55b4105d3d193e49443826f',
+ 'github:cordisx/cordisx#3cfe370eb7abf33e16686fbd82659cd441247fbd',
);
assert.equal(packageJson.main, packageManifest.entry);
assert.equal(packageManifest.entry, './dist/runtime/chatroom.js');
diff --git a/test/helpers/component-hook-harness.mjs b/test/helpers/component-hook-harness.mjs
index d877f69..b4dfd2c 100644
--- a/test/helpers/component-hook-harness.mjs
+++ b/test/helpers/component-hook-harness.mjs
@@ -7,6 +7,7 @@ export async function componentHarness(file, dependencies = {}) {
if (file === 'chatroom-timeline.tsx' && dependencies['./chatroom-timeline-entries.js'] === undefined) {
dependencies['./chatroom-timeline-entries.js'] = (await componentHarness('chatroom-timeline-entries.tsx')).exports;
}
+ const notifications = [];
const state = [];
const effects = [];
let index = 0;
@@ -47,6 +48,16 @@ export async function componentHarness(file, dependencies = {}) {
const require = name => {
if (name === './chatroom-room-actions.js') return { ChatroomRoomActions: 'RoomActions' };
if (name === './chatroom-message-body.js') return { ChatroomMessageBody: 'MessageBody' };
+ if (name === './notifications.js') {
+ return dependencies[name] ?? {
+ useNotifications: () => ({
+ show: options => {
+ notifications.push(options);
+ return { dismiss() {} };
+ },
+ }),
+ };
+ }
if (name === 'cordisx/react') return react;
if (name === './chatroom-inspector.js') {
return dependencies[name] ?? { useChatroomInspector: () => ({ width: 360, narrow: false, separatorProps: {} }) };
@@ -58,6 +69,7 @@ export async function componentHarness(file, dependencies = {}) {
new Function('require', 'exports', output)(require, exports);
return {
exports,
+ notifications,
render: (Component, props) => {
index = 0;
return Component(props);
diff --git a/test/real-cli-room.mjs b/test/real-cli-room.mjs
index 7ccef1d..e150d91 100644
--- a/test/real-cli-room.mjs
+++ b/test/real-cli-room.mjs
@@ -8,17 +8,21 @@ import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Context } from '@deepseek-ai/cordis';
-import { OwnerDocumentStore } from '../node_modules/cordisx/packages/cli/dist/src/launcher/owner-document-store.js';
-import { createOwnerDocumentBridgeHandler } from '../node_modules/cordisx/packages/cli/dist/src/launcher/owner-document-rpc.js';
-import {
+const { OwnerDocumentStore } = await import(
+ new URL('./launcher/owner-document-store.js', import.meta.resolve('cordisx/contracts'))
+);
+const { createOwnerDocumentBridgeHandler } = await import(
+ new URL('./launcher/owner-document-rpc.js', import.meta.resolve('cordisx/contracts'))
+);
+const {
BrowserOwnerDocumentBridge,
CordisXOwnerDocumentBroker,
-} from '../node_modules/cordisx/packages/cli/dist/src/renderer/owner-documents.js';
-import {
+} = await import(new URL('./renderer/owner-documents.js', import.meta.resolve('cordisx/contracts')));
+const {
dispatchAgentTool,
getAgentToolSetup,
installAgentTools,
-} from '../node_modules/cordisx/packages/cli/dist/src/renderer/plugin-agent-tools.js';
+} = await import(new URL('./renderer/plugin-agent-tools.js', import.meta.resolve('cordisx/contracts')));
import { DurableChatroomRoomStore } from '../dist/room-store.js';
import { addRoomRun, bindRoomRunSession, createRoom } from '../dist/room.js';
import { ChatroomCliBindings } from '../dist/room-cli-bindings.js';