From 031672c4e7f02cf8569506a660657704806e5039 Mon Sep 17 00:00:00 2001
From: "Roger (DatoCMS)"
Date: Wed, 4 Nov 2026 15:34:08 -0800
Subject: [PATCH 01/17] Dato Agent: resync package-lock.json so npm ci works
again
Co-Authored-By: Claude Opus 5.5
---
dato-agent/package-lock.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/dato-agent/package-lock.json b/dato-agent/package-lock.json
index 283ff4da..8d54cd0a 100644
--- a/dato-agent/package-lock.json
+++ b/dato-agent/package-lock.json
@@ -615,9 +615,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
- "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.2.0.tgz",
+ "integrity": "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw==",
"dev": true,
"license": "MIT",
"optional": true,
From d47d58dd88026577741febd44a9097d1161bc470 Mon Sep 17 00:00:00 2001
From: "Roger (DatoCMS)"
Date: Wed, 4 Nov 2026 15:38:31 -0800
Subject: [PATCH 02/17] Dato Agent: explain why chats are blocked until an AI
provider is set up
With no provider key, "New chat" changed state the setup panel never
showed, so the click did nothing (#173). Now, while the provider is
missing:
- New chat and the chat list are disabled, and the tooltip says why.
New chat uses aria-disabled so its tooltip still shows on hover.
- The "setup required" notice says chats need an API key and a model,
with an "Open plugin settings" button for users who can edit the
settings and "ask a project admin" for everyone else.
Picking a chat before connecting DatoCMS still works as before.
Fixes #173
Co-Authored-By: Claude Opus 5.5
---
.../src/components/AgentSurface.module.css | 14 +++-
.../src/components/AgentSurface.test.tsx | 65 +++++++++++++++++++
dato-agent/src/components/AgentSurface.tsx | 51 +++++++++++++--
dato-agent/src/entrypoints/AgentFrame.tsx | 3 +
dato-agent/src/entrypoints/AgentInspector.tsx | 10 ++-
dato-agent/src/entrypoints/AgentSidebar.tsx | 7 +-
dato-agent/src/lib/navigation.test.ts | 26 +++++++-
dato-agent/src/lib/navigation.ts | 11 ++++
8 files changed, 176 insertions(+), 11 deletions(-)
diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css
index f98dd120..e82c026a 100644
--- a/dato-agent/src/components/AgentSurface.module.css
+++ b/dato-agent/src/components/AgentSurface.module.css
@@ -295,7 +295,9 @@
outline: 3px solid var(--color--focus--outline);
}
-.iconButton:disabled {
+.iconButton:disabled,
+.iconButton[aria-disabled='true'],
+.iconButton[aria-disabled='true']:hover {
color: var(--color--disabled--ink);
background: transparent;
cursor: default;
@@ -330,7 +332,7 @@
.configurationNotice {
display: flex;
min-height: 34px;
- align-items: center;
+ align-items: flex-start;
gap: 8px;
margin-top: var(--spacing-m);
padding: 6px 8px;
@@ -343,9 +345,17 @@
margin-top: var(--spacing-s);
}
+.configurationNoticeBody {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
.configurationNotice svg {
width: 15px;
height: 15px;
+ margin-top: 2px;
flex: none;
color: var(--color--warning-soft--ink);
}
diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx
index fd0c5fc5..5c9a9f7f 100644
--- a/dato-agent/src/components/AgentSurface.test.tsx
+++ b/dato-agent/src/components/AgentSurface.test.tsx
@@ -1911,6 +1911,71 @@ describe('AgentSurface', () => {
expect(screen.getByRole('button', { name: 'Connect' })).toBeVisible();
});
+ it('blocks chats and offers the plugin settings while the AI provider is missing', async () => {
+ const user = userEvent.setup();
+ const onStartNewChat = vi.fn();
+ const onSelectConversation = vi.fn();
+ const onOpenPluginSettings = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Anthropic (Claude) setup required')).toBeVisible();
+ await user.click(
+ screen.getByRole('button', { name: 'Open plugin settings' }),
+ );
+ expect(onOpenPluginSettings).toHaveBeenCalledOnce();
+
+ const newChatButton = screen.getByRole('button', { name: 'New chat' });
+ expect(newChatButton).toHaveAttribute('aria-disabled', 'true');
+ await user.click(newChatButton);
+ expect(onStartNewChat).not.toHaveBeenCalled();
+
+ const pastChat = screen
+ .getByText('Update the homepage')
+ .closest('button') as HTMLElement;
+ expect(pastChat).toBeDisabled();
+ expect(pastChat).toHaveAttribute(
+ 'title',
+ 'Chats need Anthropic (Claude) to be set up first',
+ );
+ });
+
+ it('tells editors who cannot edit the plugin settings to ask an admin', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/Ask a project admin/)).toBeVisible();
+ expect(
+ screen.queryByRole('button', { name: 'Open plugin settings' }),
+ ).not.toBeInTheDocument();
+ });
+
it('closes connected settings after selecting or starting a chat', async () => {
const user = userEvent.setup();
const onSelectConversation = vi.fn();
diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx
index 0ab660d9..d1eaa1b1 100644
--- a/dato-agent/src/components/AgentSurface.tsx
+++ b/dato-agent/src/components/AgentSurface.tsx
@@ -295,6 +295,11 @@ export type AgentSurfaceProps = {
conversation: AgentConversationSummaryViewModel,
) => boolean | undefined | Promise;
onStartNewChat?: () => boolean | undefined | Promise;
+ /**
+ * Opens this plugin's settings page. Present only when the current user can
+ * edit them, so the setup notice offers a button instead of "ask an admin".
+ */
+ onOpenPluginSettings?: () => void;
onStop?: () => void;
onOpenRecord?: (
record: AgentRecordResultViewModel,
@@ -350,6 +355,7 @@ type CredentialPanelProps = Pick<
| 'recentConversations'
| 'onSelectConversation'
| 'onStartNewChat'
+ | 'onOpenPluginSettings'
>;
type AccessNoticeProps = Pick<
@@ -544,11 +550,14 @@ function DatoCmsConnectionField({
function RecentChats({
conversations,
disabled,
+ blockedReason,
onSelectConversation,
onStartNewChat,
}: {
conversations: readonly AgentConversationSummaryViewModel[];
disabled?: boolean;
+ /** Why chats can't open yet. Shown as the New chat tooltip. */
+ blockedReason?: string;
onSelectConversation?: (
conversation: AgentConversationSummaryViewModel,
) => void;
@@ -574,17 +583,19 @@ function RecentChats({
{onStartNewChat && (
+ {/* aria-disabled, not disabled: a disabled button gets no hover, so its tooltip couldn't explain why. */}
- New chat
+ {blockedReason ?? 'New chat'}
)}
@@ -599,9 +610,11 @@ function RecentChats({
- {onDisconnectDatoCms && (
+ {onReconnectDatoCms && (
// Each connection is its own OAuth client with its own access level,
- // so a fresh Connect is how an editor picks a different one.
+ // so a fresh sign-in is how an editor picks a different one.
- Disconnect to change access
+ Reconnect to change access
)}
>
@@ -598,7 +602,7 @@ function RecentChats({
function CredentialPanel({
connection,
onConnectDatoCms,
- onDisconnectDatoCms,
+ onReconnectDatoCms,
onCheckDatoCmsAccess,
recentConversations = [],
onSelectConversation,
@@ -671,7 +675,7 @@ function CredentialPanel({
@@ -2304,7 +2308,7 @@ export function AgentSurface({
onSubmit,
mentionHost,
onConnectDatoCms,
- onDisconnectDatoCms,
+ onReconnectDatoCms,
onCheckDatoCmsAccess,
recentConversations = [],
onSelectConversation,
@@ -2498,7 +2502,7 @@ export function AgentSurface({
headingRef={settingsOpen ? settingsHeadingRef : undefined}
isSettings={connected}
onConnectDatoCms={onConnectDatoCms}
- onDisconnectDatoCms={onDisconnectDatoCms}
+ onReconnectDatoCms={onReconnectDatoCms}
onCheckDatoCmsAccess={onCheckDatoCmsAccess}
onSelectConversation={
onSelectConversation
diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx
index 6e7c7e8d..e5f0471c 100644
--- a/dato-agent/src/entrypoints/AgentFrame.test.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx
@@ -8413,6 +8413,40 @@ describe('AgentFrame', () => {
expect(mocks.surfaceProps?.connection.datoCmsStatus).toBe('connected');
});
+ it('reconnects with a fresh client and revokes the previous token', async () => {
+ render();
+ await act(async () => {
+ await mocks.surfaceProps?.onConnectDatoCms?.();
+ });
+ expect(mocks.registerClient).toHaveBeenCalledOnce();
+ mocks.exchange.mockResolvedValueOnce({
+ accessToken: 'second-token',
+ tokenType: 'Bearer',
+ obtainedAt: 3,
+ });
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response(null, { status: 200 }));
+
+ act(() => {
+ mocks.surfaceProps?.onReconnectDatoCms?.();
+ });
+
+ // A fresh client means DatoCMS shows the consent screen and its access choices.
+ await waitFor(() => expect(mocks.registerClient).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
+ expect(
+ fetchSpy.mock.calls.some(
+ ([url, init]) =>
+ String(url).endsWith('/oauth/revoke') &&
+ new URLSearchParams(String(init?.body)).get('token') ===
+ 'opaque.bridge/token+value==',
+ ),
+ ).toBe(true),
+ );
+ expect(mocks.surfaceProps?.connection.datoCmsStatus).toBe('connected');
+ });
+
it('says a denied sign-in can be retried with Connect', async () => {
mocks.callback.mockRejectedValueOnce(
new RemoteMcpOAuthError(
diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx
index 4ed4aae4..d4e29f16 100644
--- a/dato-agent/src/entrypoints/AgentFrame.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.tsx
@@ -5585,6 +5585,39 @@ export default function AgentFrame(props: AgentFrameProps) {
}
};
+ /**
+ * Signs in again with a fresh OAuth client, so DatoCMS shows the consent
+ * screen and its access choices, then revokes the previous token. The access
+ * level belongs to each client, so this is how an editor changes it. Connect
+ * opens the popup before any await, so browsers don't block it.
+ */
+ const reconnectDatoCms = async () => {
+ if (
+ running ||
+ activeTurnRef.current ||
+ pendingApprovalsRef.current.size > 0 ||
+ approvalDispatchRef.current.size > 0
+ ) {
+ setOauthError(
+ 'Finish or deny the pending operation before reconnecting.',
+ );
+ return;
+ }
+ const previous = oauthCredentialsRef.current;
+ await connectDatoCms(true);
+ const previousToken = previous?.token?.accessToken;
+ if (
+ previous &&
+ previousToken &&
+ oauthCredentialsRef.current?.token?.accessToken !== previousToken
+ ) {
+ await revokeToken({
+ accessToken: previousToken,
+ clientId: previous.client.clientId,
+ }).catch(() => undefined);
+ }
+ };
+
const disconnectDatoCms = async () => {
if (
running ||
@@ -6148,6 +6181,7 @@ export default function AgentFrame(props: AgentFrameProps) {
onCopyFailureDiagnostics={copyFailureDiagnostics}
onConnectDatoCms={() => void connectDatoCms()}
onDisconnectDatoCms={() => void disconnectDatoCms()}
+ onReconnectDatoCms={() => void reconnectDatoCms()}
onCheckDatoCmsAccess={() => {
setAccessChecking(true);
void refreshMcpAccess()
diff --git a/dato-agent/src/lib/mcpAccess.ts b/dato-agent/src/lib/mcpAccess.ts
index c3f6674f..b3b1b0ca 100644
--- a/dato-agent/src/lib/mcpAccess.ts
+++ b/dato-agent/src/lib/mcpAccess.ts
@@ -13,7 +13,7 @@ export const MCP_ACCESS_CHECK_TIMEOUT_MS = 10_000;
* access level for that client only. A fresh Connect is how an editor changes it.
*/
export const MCP_ACCESS_CHANGE_GUIDANCE =
- 'To change the access level, use "Disconnect to change access" in the agent setup, then connect again and pick another level. This changes only this plugin\'s connection. The project role can still limit access.';
+ 'To change the access level, use "Reconnect to change access" in the agent setup and pick another level on the DatoCMS consent screen. This changes only this plugin\'s connection. The project role can still limit access.';
export function mcpAccessLabel(level: McpAccessLevel): string {
return {
From fb66faa44cf297cae32d29e4b7bac2cf834b225e Mon Sep 17 00:00:00 2001
From: "Roger (DatoCMS)"
Date: Wed, 4 Nov 2026 16:45:52 -0800
Subject: [PATCH 10/17] =?UTF-8?q?Dato=20Agent:=20move=20settings=20and=20a?=
=?UTF-8?q?ccess=20into=20a=20"=E2=8B=AE"=20menu;=20chats=20panel=20is=20j?=
=?UTF-8?q?ust=20chats?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Setup is for editors who aren't set up yet. Once they are, the chats
panel shows only the chat history, so nobody changes the connection by
accident.
- A "⋮" menu in the chat toolbar holds:
- "Plugin settings", disabled with a reason for users who can't edit
the schema
- the current DatoCMS access level, with one line on what it allows
- "Reconnect to change access…", which asks first
- The confirmation is a small dialog inside the plugin frame, not a host
dialog: the sign-in popup opens only from a click in this frame, and a
click on a host dialog happens in the parent page.
- The setup screen no longer offers its own reconnect button.
- Tests render the surface inside the kit's plugin ctx, and the test
setup stubs IntersectionObserver, which the kit's menu uses.
Co-Authored-By: Claude Opus 5.5
---
dato-agent/src/components/AgentIcons.tsx | 11 +
.../src/components/AgentSurface.module.css | 69 +++++
.../src/components/AgentSurface.test.tsx | 90 ++++--
dato-agent/src/components/AgentSurface.tsx | 277 +++++++++++++++---
.../src/test/datocms-react-ui-canvas.d.ts | 5 +
dato-agent/src/test/setup.ts | 13 +
6 files changed, 392 insertions(+), 73 deletions(-)
create mode 100644 dato-agent/src/test/datocms-react-ui-canvas.d.ts
diff --git a/dato-agent/src/components/AgentIcons.tsx b/dato-agent/src/components/AgentIcons.tsx
index 1440e6bf..ce39ab30 100644
--- a/dato-agent/src/components/AgentIcons.tsx
+++ b/dato-agent/src/components/AgentIcons.tsx
@@ -137,6 +137,17 @@ export function BoltIcon({ className }: IconProps) {
);
}
+/** Three vertical dots: the "more options" menu. */
+export function MoreIcon({ className }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
/** The bolt with a slash through it: auto-approve is off. */
export function BoltOffIcon({ className }: IconProps) {
return (
diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css
index 160d81f4..be0d0458 100644
--- a/dato-agent/src/components/AgentSurface.module.css
+++ b/dato-agent/src/components/AgentSurface.module.css
@@ -46,6 +46,69 @@
background-color 0.2s var(--material-ease);
}
+.utilityActions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-left: auto;
+}
+
+.dialogBackdrop {
+ position: fixed;
+ z-index: 20;
+ display: grid;
+ inset: 0;
+ padding: var(--spacing-m);
+ place-items: center;
+ background: var(--color--backdrop--surface, rgb(0 0 0 / 40%));
+}
+
+.dialog {
+ display: grid;
+ width: min(360px, 100%);
+ gap: var(--spacing-m);
+ padding: var(--spacing-l, 18px);
+ border: 1px solid var(--color--border);
+ border-radius: 6px;
+ color: var(--color--ink);
+ background: var(--color--surface);
+ box-shadow: var(--shadow--lifted, 0 12px 32px rgb(0 0 0 / 30%));
+}
+
+.dialog h2 {
+ margin: 0;
+ font-size: var(--font-size-m);
+}
+
+.dialog p {
+ margin: 0;
+ color: var(--color--ink-subtle);
+ font-size: var(--font-size-xs);
+ line-height: 1.45;
+}
+
+.dialogActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--spacing-s);
+}
+
+.optionsButton {
+ width: 30px;
+ height: 30px;
+}
+
+.optionsAccess {
+ display: grid;
+ gap: 2px;
+ max-width: 240px;
+ white-space: normal;
+}
+
+.optionsAccess span {
+ color: var(--color--ink-subtle);
+}
+
.autoApproveControl {
display: flex;
height: 30px;
@@ -437,6 +500,12 @@
resize: none;
}
+.chatHistory .recentChats {
+ margin-top: 0;
+ padding-top: 0;
+ border-top: 0;
+}
+
.recentChats {
margin-top: 18px;
padding-top: 14px;
diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx
index 199486db..5e140392 100644
--- a/dato-agent/src/components/AgentSurface.test.tsx
+++ b/dato-agent/src/components/AgentSurface.test.tsx
@@ -1,13 +1,16 @@
import {
act,
+ render as baseRender,
cleanup,
fireEvent,
- render,
+ type RenderOptions,
screen,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import type { ReactNode } from 'react';
+// The kit's Dropdown reads the plugin ctx, which