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({ + )} + )} @@ -687,6 +723,7 @@ function CredentialPanel({ @@ -2233,6 +2270,7 @@ export function AgentSurface({ recentConversations = [], onSelectConversation, onStartNewChat, + onOpenPluginSettings, onStop, onOpenRecord, onOpenField, @@ -2440,6 +2478,7 @@ export function AgentSurface({ } : undefined } + onOpenPluginSettings={onOpenPluginSettings} recentConversations={recentConversations} /> diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx index 9d648c8b..e3dd6202 100644 --- a/dato-agent/src/entrypoints/AgentFrame.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.tsx @@ -151,6 +151,8 @@ export type AgentFrameProps = { environment: string; isEnvironmentPrimary: boolean; currentUserId: string; + /** Opens the plugin settings. Pass it only when the current user can edit them. */ + onOpenPluginSettings?: () => void; /** * The DatoCMS host surface. A record sidebar can exist before its record has * a saved ID, so this must not be inferred only from `currentRecord`. @@ -6140,6 +6142,7 @@ export default function AgentFrame(props: AgentFrameProps) { }))} onSelectConversation={selectConversation} onStartNewChat={startNewConversation} + onOpenPluginSettings={props.onOpenPluginSettings} onStop={stopActiveTurn} onOpenAsset={openAsset} onOpenField={props.openCurrentField ? openField : undefined} diff --git a/dato-agent/src/entrypoints/AgentInspector.tsx b/dato-agent/src/entrypoints/AgentInspector.tsx index 5bef8970..929bb3dd 100644 --- a/dato-agent/src/entrypoints/AgentInspector.tsx +++ b/dato-agent/src/entrypoints/AgentInspector.tsx @@ -10,7 +10,10 @@ import { } from '../lib/hostContext'; import { resolveFieldsWithinDeadline } from '../lib/hostContextDeadline'; import { createAgentMentionHost } from '../lib/mentionHost'; -import { createInspectorNavigator } from '../lib/navigation'; +import { + createInspectorNavigator, + pluginSettingsPath, +} from '../lib/navigation'; import { canRoleUseDatoAgent } from '../lib/permissions'; import AgentFrame from './AgentFrame'; import AgentUnavailableFrame from './AgentUnavailableFrame'; @@ -270,6 +273,11 @@ function AuthorizedAgentInspector({ ctx }: Props) { void ctx.navigateTo(pluginSettingsPath(ctx, ctx.plugin.id)) + : undefined + } siteId={ctx.site.id} siteName={ctx.site.attributes.name} environment={ctx.environment} diff --git a/dato-agent/src/entrypoints/AgentSidebar.tsx b/dato-agent/src/entrypoints/AgentSidebar.tsx index 4841a6ea..1172bb53 100644 --- a/dato-agent/src/entrypoints/AgentSidebar.tsx +++ b/dato-agent/src/entrypoints/AgentSidebar.tsx @@ -25,7 +25,7 @@ import { } from '../lib/hostContext'; import { resolveFieldsWithinDeadline } from '../lib/hostContextDeadline'; import { createAgentMentionHost } from '../lib/mentionHost'; -import { createSidebarNavigator } from '../lib/navigation'; +import { createSidebarNavigator, pluginSettingsPath } from '../lib/navigation'; import { canRoleUseDatoAgent } from '../lib/permissions'; import { usePersistedSidebarWidth } from '../lib/persistedWidth'; import AgentFrame, { type AgentFrameProps } from './AgentFrame'; @@ -493,6 +493,11 @@ function AuthorizedAgentSidebar({ ctx }: Props) { void ctx.navigateTo(pluginSettingsPath(ctx, ctx.plugin.id)) + : undefined + } siteId={ctx.site.id} siteName={ctx.site.attributes.name} environment={ctx.environment} diff --git a/dato-agent/src/lib/navigation.test.ts b/dato-agent/src/lib/navigation.test.ts index 13cd7b97..dbd4eb3d 100644 --- a/dato-agent/src/lib/navigation.test.ts +++ b/dato-agent/src/lib/navigation.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { createInspectorNavigator, createSidebarNavigator } from './navigation'; +import { + createInspectorNavigator, + createSidebarNavigator, + pluginSettingsPath, +} from './navigation'; describe('createInspectorNavigator', () => { it('uses the native record list and editor', async () => { @@ -443,3 +447,23 @@ describe('createSidebarNavigator', () => { expect(ctx.editItem).toHaveBeenNthCalledWith(2, 'another'); }); }); + +describe('pluginSettingsPath', () => { + it('needs no prefix on the primary environment', () => { + expect( + pluginSettingsPath( + { environment: 'main', isEnvironmentPrimary: true }, + 'plugin-1', + ), + ).toBe('/configuration/plugins/plugin-1/edit'); + }); + + it('prefixes sandbox environments', () => { + expect( + pluginSettingsPath( + { environment: 'sandbox', isEnvironmentPrimary: false }, + 'plugin-1', + ), + ).toBe('/environments/sandbox/configuration/plugins/plugin-1/edit'); + }); +}); diff --git a/dato-agent/src/lib/navigation.ts b/dato-agent/src/lib/navigation.ts index 6c7803f8..3d463a56 100644 --- a/dato-agent/src/lib/navigation.ts +++ b/dato-agent/src/lib/navigation.ts @@ -133,3 +133,14 @@ export function createSidebarNavigator( }, }; } + +/** This plugin's settings page. Sandbox paths need the environment prefix; the primary has none. */ +export function pluginSettingsPath( + ctx: { environment: string; isEnvironmentPrimary: boolean }, + pluginId: string, +): string { + const prefix = ctx.isEnvironmentPrimary + ? '' + : `/environments/${ctx.environment}`; + return `${prefix}/configuration/plugins/${pluginId}/edit`; +} From 5f1dda7202e76ff653edd73dc3b8435fb2e75698 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 15:46:23 -0800 Subject: [PATCH 03/17] Dato Agent: split model limit parsing out to satisfy Biome's complexity rule Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/lib/providerModels.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/dato-agent/src/lib/providerModels.ts b/dato-agent/src/lib/providerModels.ts index 1957a9b1..571765b5 100644 --- a/dato-agent/src/lib/providerModels.ts +++ b/dato-agent/src/lib/providerModels.ts @@ -280,15 +280,24 @@ function extractAnthropicModel(entry: unknown): ProviderModel | undefined { const displayName = typeof entry.display_name === 'string' ? entry.display_name.trim() : ''; - const maxOutputTokens = positiveSafeInteger(entry.max_tokens); - const contextWindowTokens = positiveSafeInteger(entry.max_input_tokens); return { id, label: displayName || id, + ...anthropicModelLimits(entry), + reasoningEfforts: [...reasoningEfforts], + }; +} + +/** Context and output limits, when the Models API reports them. */ +function anthropicModelLimits( + entry: Record, +): Pick { + const maxOutputTokens = positiveSafeInteger(entry.max_tokens); + const contextWindowTokens = positiveSafeInteger(entry.max_input_tokens); + return { ...(contextWindowTokens ? { contextWindowTokens } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}), - reasoningEfforts: [...reasoningEfforts], }; } From fe9a834e3b47a677338307c22af7c21024bcf35e Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 15:46:23 -0800 Subject: [PATCH 04/17] Dato Agent: keep the provider name out of the setup notice Editors don't need to know which provider an admin picked. The notice now says "Setup required", the tooltip says chats need an AI provider, and editors who can't edit the settings are told to ask a developer or project admin for help. Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/components/AgentSurface.module.css | 4 ++-- dato-agent/src/components/AgentSurface.test.tsx | 11 +++++++---- dato-agent/src/components/AgentSurface.tsx | 11 ++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css index e82c026a..0f321540 100644 --- a/dato-agent/src/components/AgentSurface.module.css +++ b/dato-agent/src/components/AgentSurface.module.css @@ -296,8 +296,8 @@ } .iconButton:disabled, -.iconButton[aria-disabled='true'], -.iconButton[aria-disabled='true']:hover { +.iconButton[aria-disabled="true"], +.iconButton[aria-disabled="true"]:hover { color: var(--color--disabled--ink); background: transparent; cursor: default; diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx index 5c9a9f7f..4a60b7e2 100644 --- a/dato-agent/src/components/AgentSurface.test.tsx +++ b/dato-agent/src/components/AgentSurface.test.tsx @@ -1803,7 +1803,7 @@ describe('AgentSurface', () => { />, ); - expect(screen.getByText('OpenAI setup required')).toBeVisible(); + expect(screen.getByText('Setup required')).toBeVisible(); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); expect( screen.queryByRole('switch', { @@ -1934,7 +1934,8 @@ describe('AgentSurface', () => { />, ); - expect(screen.getByText('Anthropic (Claude) setup required')).toBeVisible(); + expect(screen.getByText('Setup required')).toBeVisible(); + expect(screen.queryByText(/Anthropic/)).not.toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'Open plugin settings' }), ); @@ -1951,7 +1952,7 @@ describe('AgentSurface', () => { expect(pastChat).toBeDisabled(); expect(pastChat).toHaveAttribute( 'title', - 'Chats need Anthropic (Claude) to be set up first', + 'Chats need an AI provider to be set up first', ); }); @@ -1970,7 +1971,9 @@ describe('AgentSurface', () => { />, ); - expect(screen.getByText(/Ask a project admin/)).toBeVisible(); + expect( + screen.getByText(/Ask a developer or project admin for help/), + ).toBeVisible(); expect( screen.queryByRole('button', { name: 'Open plugin settings' }), ).not.toBeInTheDocument(); diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx index d1eaa1b1..71da3416 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -661,13 +661,10 @@ function CredentialPanel({ }) { const providerConfigStatus = connection.providerConfigStatus ?? connection.openAiConfigStatus; - const configuredProviderLabel = - connection.providerLabel ?? - (connection.openAiConfigStatus ? 'OpenAI' : 'AI provider'); const isProviderMissing = providerConfigStatus === 'missing'; // Connecting DatoCMS can come after picking a chat; a missing provider can't. const chatsBlockedReason = isProviderMissing - ? `Chats need ${configuredProviderLabel} to be set up first` + ? 'Chats need an AI provider to be set up first' : undefined; return ( @@ -692,11 +689,11 @@ function CredentialPanel({
- {configuredProviderLabel} setup required + Setup required {onOpenPluginSettings - ? 'Chats need an API key and a model. Add them in the plugin settings.' - : 'Chats need an API key and a model. Ask a project admin to add them in the plugin settings.'} + ? 'Chats need an AI provider. Add its API key and pick a model in the plugin settings.' + : 'Chats need an AI provider, set up in the plugin settings. Ask a developer or project admin for help.'} {onOpenPluginSettings && ( - {blockedReason ?? 'New chat'} + New chat )}
@@ -610,11 +606,9 @@ function RecentChats({ - )} -
- - )} - - - +
    + + {!isProviderReady && ( +
    +

    + {onOpenPluginSettings + ? 'Add an API key and pick a model in the plugin settings.' + : 'A developer or project admin sets this up in the plugin settings. Ask them for help.'} +

    + {onOpenPluginSettings && ( + + )} +
    + )} +
    + + + +
+ {isSetupComplete ? ( + + ) : ( + + )}
); } +/** One numbered setup step, with a status that reads without color. */ +function SetupStep({ + number, + title, + isDone, + children, +}: { + number: number; + title: string; + isDone: boolean; + children?: ReactNode; +}) { + return ( +
  • +
    + + {title} + + {isDone ? : } + {isDone ? 'Done' : 'To do'} + +
    + {children} +
  • + ); +} + +/** Stands in for the chat list until both setup steps are done. */ +function ChatsAfterSetup() { + const headingId = useId(); + return ( +
    +
    +
    + +

    Chats

    +
    +
    +
    + Finish setup first to start chatting. +
    +
    + ); +} + function EmptyState() { return (
    From d43bbbeecaefc633f2d52fd8024b9bf2ad67f907 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:09:01 -0800 Subject: [PATCH 06/17] Dato Agent: tidy the setup steps and explain the access choices - Rename the panel "Agent setup", with the steps "Choose AI provider & model" and "Connect AI to DatoCMS MCP". - Step 2 matches step 1: a short intro and a regular Connect button, without the logo row. It lists what each access choice on the DatoCMS consent screen lets the agent do. - Until setup is done, Chats shows the kit's disabled text box with a FieldError explaining why, instead of placeholder-like text. - Step 2 is a plain container instead of the kit's Form, which wrapped the text and the button in one item and dropped the gap between them. Co-Authored-By: Claude Opus 5.5 --- .../src/components/AgentSurface.module.css | 34 +++- .../src/components/AgentSurface.test.tsx | 23 +-- dato-agent/src/components/AgentSurface.tsx | 161 +++++++++--------- 3 files changed, 130 insertions(+), 88 deletions(-) diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css index 60e817c4..160d81f4 100644 --- a/dato-agent/src/components/AgentSurface.module.css +++ b/dato-agent/src/components/AgentSurface.module.css @@ -395,7 +395,7 @@ .setupStepBody { display: grid; justify-items: start; - gap: var(--spacing-s); + gap: var(--spacing-m); margin-left: 28px; color: var(--color--ink-subtle); font-size: var(--font-size-xs); @@ -405,6 +405,38 @@ margin: 0; } +/* The access notices line up with the step's own text inside a setup step. */ +.setupStepBody .authorizationNotice { + display: grid; + justify-items: start; + gap: var(--spacing-s); + margin: 0; +} + +.accessChoices { + display: grid; + gap: 4px; +} + +.accessChoices ul { + margin: 0; + padding-left: 16px; +} + +.accessChoices li + li { + margin-top: 2px; +} + +.chatsAfterSetup { + display: grid; + gap: var(--spacing-s); + margin-top: 6px; +} + +.chatsAfterSetup textarea { + resize: none; +} + .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 2f000337..b90c2f60 100644 --- a/dato-agent/src/components/AgentSurface.test.tsx +++ b/dato-agent/src/components/AgentSurface.test.tsx @@ -1803,7 +1803,7 @@ describe('AgentSurface', () => { />, ); - expect(screen.getByText('AI provider')).toBeVisible(); + expect(screen.getByText('Choose AI provider & model')).toBeVisible(); expect(screen.getAllByText('To do')).toHaveLength(2); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); expect( @@ -1837,17 +1837,16 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Open chats' })); expect(screen.getByRole('button', { name: 'Back to chat' })).toBeVisible(); - expect(screen.getByRole('heading', { name: 'Connection' })).toHaveFocus(); + expect(screen.getByRole('heading', { name: 'Agent setup' })).toHaveFocus(); expect(screen.getByText('Marketing website')).toBeVisible(); - expect(screen.getByRole('img', { name: 'Connected' })).toBeVisible(); + expect(screen.getAllByText('Done')).toHaveLength(2); expect(screen.queryByText('Remote MCP connection')).not.toBeInTheDocument(); expect(screen.queryByRole('note')).not.toBeInTheDocument(); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); const disconnectButton = screen.getByRole('button', { - name: 'Disconnect DatoCMS', + name: 'Disconnect', }); - expect(disconnectButton).toHaveTextContent(''); - expect(within(disconnectButton).queryByRole('img')).not.toBeInTheDocument(); + expect(disconnectButton).toHaveTextContent('Disconnect'); await user.click(disconnectButton); expect(onDisconnectDatoCms).toHaveBeenCalledOnce(); }); @@ -1919,9 +1918,9 @@ describe('AgentSurface', () => { ); const [providerStep, datoCmsStep] = screen.getAllByRole('listitem'); - expect(providerStep).toHaveTextContent('AI provider'); + expect(providerStep).toHaveTextContent('Choose AI provider & model'); expect(providerStep).toHaveTextContent('To do'); - expect(datoCmsStep).toHaveTextContent('DatoCMS account'); + expect(datoCmsStep).toHaveTextContent('Connect AI to DatoCMS MCP'); expect(datoCmsStep).toHaveTextContent('Done'); // Editors don't need to know which provider an admin picked. expect(screen.queryByText(/Anthropic/)).not.toBeInTheDocument(); @@ -2033,7 +2032,7 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Open chats' })); await user.click(screen.getByRole('button', { name: 'New chat' })); - expect(screen.getByRole('heading', { name: 'Connection' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Agent setup' })).toBeVisible(); }); it('shows no chat controls until both setup steps are done', () => { @@ -2061,7 +2060,11 @@ describe('AgentSurface', () => { screen.queryByRole('button', { name: 'New chat' }), ).not.toBeInTheDocument(); expect(screen.queryByText('Describe this project')).not.toBeInTheDocument(); - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + const messageBox = screen.getByRole('textbox', { name: 'Message' }); + expect(messageBox).toBeDisabled(); + expect(messageBox).toHaveAccessibleDescription( + 'Finish setup first to start chatting.', + ); }); it('keeps the session-wide auto-approve control inside the chat', async () => { diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx index 893aea75..b22d6e3a 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -1,8 +1,9 @@ import { BackIcon, Button, - Form, + FieldError, Spinner, + TextareaInput, Tooltip, TooltipContent, TooltipDelayGroup, @@ -17,7 +18,6 @@ import { useRef, useState, } from 'react'; -import datoMarkUrl from '../assets/dato-mark.svg'; import { DEFAULT_MAX_CONVERSATIONS } from '../lib/conversations'; import { MCP_ACCESS_CHANGE_GUIDANCE, @@ -41,7 +41,6 @@ import { CircleCheckIcon, ConnectionIcon, CopyIcon, - DisconnectIcon, EditIcon, HistoryIcon, PlusIcon, @@ -460,75 +459,65 @@ function DatoCmsConnectionField({ : 'DatoCMS'; return ( -
    { - event.preventDefault(); - onConnectDatoCms?.(); - }} - > -
    -
    - - - - {connectedLabel} - - {datoCmsConnecting ? ( - - - - ) : datoCmsConnected ? ( - <> - - - - {onDisconnectDatoCms && ( - - - - - Disconnect - - )} - - ) : ( - - )} - -
    - - {!datoCmsConnected && ( -

    - When DatoCMS asks which projects to authorize, select{' '} - only the project where this plugin is installed. +

    + {datoCmsConnected ? ( + <> +

    + Connected to {connectedLabel}.

    - )} -
    + {onDisconnectDatoCms && ( + + )} + + ) : ( + <> +

    + Sign in to DatoCMS. When it asks which projects to authorize, select + only the project where this plugin is installed. +

    +
    +

    The access you pick there sets what the agent can do:

    +
      +
    • + Only read content: it can look things up, but + never change anything. +
    • +
    • + Read and edit content: it can also create, + publish and delete records and assets. +
    • +
    • + Anything you can: it can also change the + schema, settings, users and API tokens. +
    • +
    +

    + Your project role still applies, and you approve each change + unless you turn on auto-approve. +

    +
    + + + )} )} - +
    ); } @@ -673,13 +662,17 @@ function CredentialPanel({ ref={headingRef} tabIndex={isSettings ? -1 : undefined} > - Connection + Agent setup
      - + {!isProviderReady && (

      @@ -700,7 +693,11 @@ function CredentialPanel({

      )}
      - +
      @@ -765,8 +763,17 @@ function ChatsAfterSetup() {

      Chats

      -
      - Finish setup first to start chatting. +
      + +
      + Finish setup first to start chatting. +
    ); From 7bdbe894d35bbedcc2df9b0381fd6bc01729c767 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:12:53 -0800 Subject: [PATCH 07/17] Dato Agent: clearer auto-approve toggle, no access flash on load - Auto-approve off shows a slashed bolt and "Auto-approve is off". On keeps the bolt and reads "Auto: On" with a "Danger" badge. - The "DatoCMS access is not verified" notice flashed above the composer on every load: the access level reads "unknown" until the first check returns. The notice now waits for that check, so it only shows when access really couldn't be verified. Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/components/AgentIcons.tsx | 10 ++++ .../src/components/AgentSurface.test.tsx | 30 ++++++++++-- dato-agent/src/components/AgentSurface.tsx | 49 ++++++++++++++++--- dato-agent/src/entrypoints/AgentFrame.tsx | 9 +++- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/dato-agent/src/components/AgentIcons.tsx b/dato-agent/src/components/AgentIcons.tsx index 95436c3d..1440e6bf 100644 --- a/dato-agent/src/components/AgentIcons.tsx +++ b/dato-agent/src/components/AgentIcons.tsx @@ -137,6 +137,16 @@ export function BoltIcon({ className }: IconProps) { ); } +/** The bolt with a slash through it: auto-approve is off. */ +export function BoltOffIcon({ className }: IconProps) { + return ( + + + + + ); +} + export function RetryIcon({ className }: IconProps) { return ( diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx index b90c2f60..2368fdc1 100644 --- a/dato-agent/src/components/AgentSurface.test.tsx +++ b/dato-agent/src/components/AgentSurface.test.tsx @@ -2095,8 +2095,8 @@ describe('AgentSurface', () => { Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect(enableButton).toHaveAttribute('aria-pressed', 'false'); - expect(enableButton).toHaveTextContent('Auto'); - expect(enableButton).not.toHaveTextContent('Danger!'); + expect(enableButton).toHaveTextContent('Auto-approve is off'); + expect(enableButton).not.toHaveTextContent('Danger'); await user.click(enableButton); expect(onAutoApproveChange).toHaveBeenCalledWith(true); @@ -2130,8 +2130,8 @@ describe('AgentSurface', () => { name: 'Turn off auto-approve', }); expect(disableButton).toHaveAttribute('aria-pressed', 'true'); - expect(disableButton).toHaveTextContent('Auto'); - expect(disableButton).toHaveTextContent('Danger!'); + expect(disableButton).toHaveTextContent('Auto: On'); + expect(disableButton).toHaveTextContent('Danger'); await user.click(disableButton); expect(onAutoApproveChange).toHaveBeenLastCalledWith(false); }); @@ -2377,6 +2377,28 @@ describe('AgentSurface', () => { expect(onCheckDatoCmsAccess).toHaveBeenCalledOnce(); }); + it('shows no unverified-access notice before the first access check returns', () => { + render( + , + ); + expect( + screen.queryByText(/access is not verified/), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Checking access…' }), + ).not.toBeInTheDocument(); + }); + it('shows OAuth access separately from plugin Read Only and explains how to change it', async () => { render(

    @@ -2208,6 +2216,34 @@ function Transcript({ ); } +/** Icon and text of the auto-approve toggle: a slashed bolt when off, a danger badge when on. */ +function AutoApproveLabel({ + isChanging, + isEnabled, +}: { + isChanging: boolean; + isEnabled: boolean; +}) { + const icon = isChanging ? ( + + ) : isEnabled ? ( + + ) : ( + + ); + return ( + <> + {icon} + {isEnabled ? 'Auto: On' : 'Auto-approve is off'} + {isEnabled && ( + + )} + + ); +} + function UtilityControls({ connected, chatsOpen, @@ -2280,13 +2316,10 @@ function UtilityControls({ }} type="button" > - {autoApproveChanging ? : } - Auto - {autoApproveEnabled && ( - - )} + {autoApproveLabel} diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx index e3dd6202..ee1b361d 100644 --- a/dato-agent/src/entrypoints/AgentFrame.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.tsx @@ -1992,6 +1992,8 @@ export default function AgentFrame(props: AgentFrameProps) { const forceFreshRegistrationRef = useRef(false); const oauthFlowGenerationRef = useRef(0); const [accessChecking, setAccessChecking] = useState(false); + /** The token whose first access check has finished, so "unknown" means unverified, not "not checked yet". */ + const [accessCheckedToken, setAccessCheckedToken] = useState(''); const accessLevel = accessState.token === oauthCredentials?.token?.accessToken ? accessState.snapshot.level @@ -2925,7 +2927,9 @@ export default function AgentFrame(props: AgentFrameProps) { .current(controller.signal) .catch(() => undefined) .finally(() => { - if (!controller.signal.aborted) setAccessChecking(false); + if (controller.signal.aborted) return; + setAccessChecking(false); + setAccessCheckedToken(token); }); return () => controller.abort(); }, [accessSession, oauthCredentials?.token?.accessToken]); @@ -6091,6 +6095,9 @@ export default function AgentFrame(props: AgentFrameProps) { oauthAccessLevel: accessLevel, pluginReadOnly: props.config.readOnly, accessChecking, + accessCheckPending: + hasDatoCmsConnection && + accessCheckedToken !== oauthCredentials?.token?.accessToken, datoCmsAccountLabel: hasDatoCmsConnection ? 'DatoCMS' : undefined, datoCmsError: oauthError, }} From 6dc7895b91969a30504d4032b24aa366cd4b1b33 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:21:38 -0800 Subject: [PATCH 08/17] Dato Agent: one button per job in the DatoCMS step, and correct guidance Each Connect registers its own OAuth client, and the consent screen stores the projects and the access level for that client only (OauthApplicationAccountSetting, keyed by account and application). A public client always sees the consent screen again. So disconnecting and connecting again is how an editor changes the access level, and it touches no other client's grant. - Connected state: "Current access level", then one "Disconnect to change access" button. Drop "Check access again" (the plugin checks on every sign-in) and the account-settings link. - Fix MCP_ACCESS_CHANGE_GUIDANCE, which the system prompt also uses: it told editors to revoke access in their account settings, which affects every MCP client of the account. - Sign-in errors: drop "Start sign-in again with a new registration". Connect already re-registers when DatoCMS rejects the stored client (invalid_client), and no other failure needs a new client. Errors now say what happened and end with "Click Connect to try again." Co-Authored-By: Claude Opus 5.5 --- .../src/components/AgentSurface.test.tsx | 20 +++--- dato-agent/src/components/AgentSurface.tsx | 62 +++---------------- .../src/entrypoints/AgentFrame.test.tsx | 31 ++++++++-- dato-agent/src/entrypoints/AgentFrame.tsx | 28 ++++++--- dato-agent/src/lib/mcpAccess.ts | 8 ++- 5 files changed, 71 insertions(+), 78 deletions(-) diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx index 2368fdc1..78eb3bde 100644 --- a/dato-agent/src/components/AgentSurface.test.tsx +++ b/dato-agent/src/components/AgentSurface.test.tsx @@ -1844,9 +1844,8 @@ describe('AgentSurface', () => { expect(screen.queryByRole('note')).not.toBeInTheDocument(); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); const disconnectButton = screen.getByRole('button', { - name: 'Disconnect', + name: 'Disconnect to change access', }); - expect(disconnectButton).toHaveTextContent('Disconnect'); await user.click(disconnectButton); expect(onDisconnectDatoCms).toHaveBeenCalledOnce(); }); @@ -2408,6 +2407,7 @@ describe('AgentSurface', () => { pluginReadOnly: true, }} entries={[]} + onDisconnectDatoCms={vi.fn()} onSubmit={vi.fn()} />, ); @@ -2421,14 +2421,16 @@ describe('AgentSurface', () => { expect( screen.getByText(/Schema and management changes are unavailable/), ).toBeVisible(); + expect(screen.getByText(/Current access level/)).toBeVisible(); + // Each connection is its own OAuth client, so reconnecting changes the level. expect( - screen.getByRole('link', { name: 'Account settings' }), - ).toHaveAttribute( - 'href', - 'https://dashboard.datocms.com/personal-account/account', - ); - expect( - screen.getByText(/This affects all MCP clients of your account/), + screen.getByRole('button', { name: 'Disconnect to change access' }), ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Check access again' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Account settings' }), + ).not.toBeInTheDocument(); }); }); diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx index f1890ddf..b35bd426 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -19,12 +19,7 @@ import { useState, } from 'react'; import { DEFAULT_MAX_CONVERSATIONS } from '../lib/conversations'; -import { - MCP_ACCESS_CHANGE_GUIDANCE, - MCP_ACCESS_CHANGE_URL, - type McpAccessLevel, - mcpAccessLabel, -} from '../lib/mcpAccess'; +import { type McpAccessLevel, mcpAccessLabel } from '../lib/mcpAccess'; import type { AgentMentionHost } from '../lib/mentionHost'; import type { AgentComposerSubmission, @@ -292,7 +287,6 @@ export type AgentSurfaceProps = { onConnectDatoCms?: () => void; onDisconnectDatoCms?: () => void; onCheckDatoCmsAccess?: () => void; - onRestartDatoCmsConnection?: () => void; recentConversations?: readonly AgentConversationSummaryViewModel[]; onSelectConversation?: ( conversation: AgentConversationSummaryViewModel, @@ -354,7 +348,6 @@ type CredentialPanelProps = Pick< | 'onConnectDatoCms' | 'onDisconnectDatoCms' | 'onCheckDatoCmsAccess' - | 'onRestartDatoCmsConnection' | 'recentConversations' | 'onSelectConversation' | 'onStartNewChat' @@ -366,23 +359,23 @@ type AccessNoticeProps = Pick< 'connection' | 'onCheckDatoCmsAccess' >; +/** What the current DatoCMS connection lets the agent do. */ function OAuthAccessNotice({ connection, - onCheckDatoCmsAccess, -}: AccessNoticeProps) { +}: Pick) { if (connection.datoCmsStatus !== 'connected' || !connection.oauthAccessLevel) return null; return (

    - OAuth access:{' '} + Current access level:{' '} {mcpAccessLabel(connection.oauthAccessLevel)}

    {connection.pluginReadOnly && (

    Plugin Read Only is enabled. All agent writes are disabled.

    )} {connection.oauthAccessLevel === 'unknown' && ( -

    Read-only work is available. Check access before making changes.

    +

    Not verified yet. Read-only work is available until it is.

    )} {connection.oauthAccessLevel === 'content_only' && (

    @@ -393,24 +386,6 @@ function OAuthAccessNotice({ {connection.oauthAccessLevel === 'content_view_only' && (

    All agent writes, including asset creation, are disabled.

    )} - {(connection.oauthAccessLevel === 'content_view_only' || - connection.oauthAccessLevel === 'content_only') && ( -

    - {MCP_ACCESS_CHANGE_GUIDANCE}{' '} - - Account settings - -

    - )} -
    ); } @@ -448,15 +423,9 @@ function DatoCmsConnectionField({ connection, onConnectDatoCms, onDisconnectDatoCms, - onCheckDatoCmsAccess, - onRestartDatoCmsConnection, }: Pick< CredentialPanelProps, - | 'connection' - | 'onConnectDatoCms' - | 'onDisconnectDatoCms' - | 'onCheckDatoCmsAccess' - | 'onRestartDatoCmsConnection' + 'connection' | 'onConnectDatoCms' | 'onDisconnectDatoCms' >) { const datoCmsConnected = connection.datoCmsStatus === 'connected'; const datoCmsConnecting = connection.datoCmsStatus === 'connecting'; @@ -473,14 +442,17 @@ function DatoCmsConnectionField({

    Connected to {connectedLabel}.

    + {onDisconnectDatoCms && ( + // Each connection is its own OAuth client with its own access level, + // so a fresh Connect is how an editor picks a different one. )} @@ -527,18 +499,9 @@ function DatoCmsConnectionField({ )} - {connection.datoCmsError && (

    {connection.datoCmsError} - {!datoCmsConnecting && onRestartDatoCmsConnection && ( - - )}

    )} @@ -637,7 +600,6 @@ function CredentialPanel({ onConnectDatoCms, onDisconnectDatoCms, onCheckDatoCmsAccess, - onRestartDatoCmsConnection, recentConversations = [], onSelectConversation, onStartNewChat, @@ -710,8 +672,6 @@ function CredentialPanel({ connection={connection} onConnectDatoCms={onConnectDatoCms} onDisconnectDatoCms={onDisconnectDatoCms} - onCheckDatoCmsAccess={onCheckDatoCmsAccess} - onRestartDatoCmsConnection={onRestartDatoCmsConnection} /> @@ -2346,7 +2306,6 @@ export function AgentSurface({ onConnectDatoCms, onDisconnectDatoCms, onCheckDatoCmsAccess, - onRestartDatoCmsConnection, recentConversations = [], onSelectConversation, onStartNewChat, @@ -2541,7 +2500,6 @@ export function AgentSurface({ onConnectDatoCms={onConnectDatoCms} onDisconnectDatoCms={onDisconnectDatoCms} onCheckDatoCmsAccess={onCheckDatoCmsAccess} - onRestartDatoCmsConnection={onRestartDatoCmsConnection} onSelectConversation={ onSelectConversation ? (conversation) => { diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx index fc5aa03e..6e7c7e8d 100644 --- a/dato-agent/src/entrypoints/AgentFrame.test.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx @@ -8380,8 +8380,8 @@ describe('AgentFrame', () => { await mocks.surfaceProps?.onConnectDatoCms?.(); }); expect(mocks.exchange).toHaveBeenCalledOnce(); - expect(mocks.surfaceProps?.connection.datoCmsError).toContain( - 'Start sign-in again', + expect(mocks.surfaceProps?.connection.datoCmsError).toBe( + 'This sign-in code expired. Click Connect to try again.', ); await act(async () => { await mocks.surfaceProps?.onConnectDatoCms?.(); @@ -8393,7 +8393,7 @@ describe('AgentFrame', () => { ]); }); - it('offers an explicit fresh-registration retry after no callback can be returned', async () => { + it('recovers with a plain Connect after the sign-in popup was closed', async () => { mocks.callback.mockRejectedValueOnce( new Error('The sign-in popup was closed before authorization completed'), ); @@ -8401,15 +8401,34 @@ describe('AgentFrame', () => { await act(async () => { await mocks.surfaceProps?.onConnectDatoCms?.(); }); - expect(mocks.registerClient).toHaveBeenCalledOnce(); + expect(mocks.surfaceProps?.connection.datoCmsError).toBe( + 'Could not connect to DatoCMS: The sign-in popup was closed before authorization completed. Click Connect to try again.', + ); await act(async () => { - await mocks.surfaceProps?.onRestartDatoCmsConnection?.(); + await mocks.surfaceProps?.onConnectDatoCms?.(); }); + // The registered client is still valid, so Connect reuses it. expect(mocks.openPopup).toHaveBeenCalledTimes(2); - expect(mocks.registerClient).toHaveBeenCalledTimes(2); + expect(mocks.registerClient).toHaveBeenCalledOnce(); expect(mocks.surfaceProps?.connection.datoCmsStatus).toBe('connected'); }); + it('says a denied sign-in can be retried with Connect', async () => { + mocks.callback.mockRejectedValueOnce( + new RemoteMcpOAuthError( + 'OAuth authorization failed (access_denied): The resource owner or authorization server denied the request.', + { code: 'access_denied' }, + ), + ); + render(); + await act(async () => { + await mocks.surfaceProps?.onConnectDatoCms?.(); + }); + expect(mocks.surfaceProps?.connection.datoCmsError).toBe( + 'The DatoCMS sign-in was cancelled or denied. Click Connect to try again.', + ); + }); + it.each(['unknown', 'content_view_only', 'content_only'] as const)( 'invalidates a pending approval when access narrows to %s', async (level) => { diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx index ee1b361d..4ed4aae4 100644 --- a/dato-agent/src/entrypoints/AgentFrame.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.tsx @@ -1836,6 +1836,25 @@ function recoverUnsafeDispatchJournal( return loadStoredConversations(conversationStore); } +/** + * Sign-in failures in words an editor can act on. Connect registers a fresh + * OAuth client by itself when DatoCMS rejects the stored one, so every failure + * here is fixed by clicking Connect again. + */ +function datoCmsSignInErrorMessage(error: unknown): string { + const retry = 'Click Connect to try again.'; + if (error instanceof RemoteMcpOAuthError) { + if (error.code === 'access_denied') + return `The DatoCMS sign-in was cancelled or denied. ${retry}`; + if (error.code === 'invalid_grant') + return `This sign-in code expired. ${retry}`; + } + const detail = error instanceof Error ? error.message.trim() : ''; + return detail + ? `Could not connect to DatoCMS: ${detail.replace(/\.?$/, '.')} ${retry}` + : `Could not connect to DatoCMS. ${retry}`; +} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This frame coordinates the chat, OAuth, persistence, native navigation, and approval state that must share one React lifecycle. export default function AgentFrame(props: AgentFrameProps) { const mentionHost = useMemo( @@ -5556,13 +5575,7 @@ export default function AgentFrame(props: AgentFrameProps) { mountedRef.current && oauthFlowGenerationRef.current === flowGeneration ) { - setOauthError( - error instanceof RemoteMcpOAuthError && error.code === 'invalid_grant' - ? 'This sign-in code is no longer valid. Start sign-in again to get a new code.' - : error instanceof Error - ? error.message - : 'Could not connect to DatoCMS.', - ); + setOauthError(datoCmsSignInErrorMessage(error)); } } finally { if (oauthFlowGenerationRef.current === flowGeneration) { @@ -6141,7 +6154,6 @@ export default function AgentFrame(props: AgentFrameProps) { .catch(() => undefined) .finally(() => setAccessChecking(false)); }} - onRestartDatoCmsConnection={() => void connectDatoCms(true)} recentConversations={storedConversations.map((stored) => ({ id: stored.id, title: stored.title, diff --git a/dato-agent/src/lib/mcpAccess.ts b/dato-agent/src/lib/mcpAccess.ts index 23a5391e..c3f6674f 100644 --- a/dato-agent/src/lib/mcpAccess.ts +++ b/dato-agent/src/lib/mcpAccess.ts @@ -8,10 +8,12 @@ export type McpAccessLevel = | 'unknown'; export const MCP_ACCESS_CHECK_TIMEOUT_MS = 10_000; -export const MCP_ACCESS_CHANGE_URL = - 'https://dashboard.datocms.com/personal-account/account'; +/** + * Each Connect registers its own OAuth client, and the consent screen stores the + * access level for that client only. A fresh Connect is how an editor changes it. + */ export const MCP_ACCESS_CHANGE_GUIDANCE = - 'To change OAuth access, revoke the authorization in your DatoCMS account settings, then reconnect. This affects all MCP clients of your account. Your project role can still limit access.'; + '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.'; export function mcpAccessLabel(level: McpAccessLevel): string { return { From 26ea39d32750917f34f59dadb473e684ef360752 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:30:26 -0800 Subject: [PATCH 09/17] Dato Agent: "Reconnect to change access" goes straight to the consent screen The connected DatoCMS step now offers "Reconnect to change access" instead of a disconnect button. It runs a forced fresh sign-in, so DatoCMS shows the consent screen with its access choices, and then revokes the previous token. Connect opens the popup before any network call, so browsers don't block it. Pending approvals block it, as they block Disconnect. If the editor cancels, they end up disconnected with the old token revoked, and the error says to click Connect. The agent's own guidance now points to the same button. Co-Authored-By: Claude Opus 5.5 --- .../src/components/AgentSurface.test.tsx | 16 ++++----- dato-agent/src/components/AgentSurface.tsx | 24 +++++++------ .../src/entrypoints/AgentFrame.test.tsx | 34 +++++++++++++++++++ dato-agent/src/entrypoints/AgentFrame.tsx | 34 +++++++++++++++++++ dato-agent/src/lib/mcpAccess.ts | 2 +- 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx index 78eb3bde..199486db 100644 --- a/dato-agent/src/components/AgentSurface.test.tsx +++ b/dato-agent/src/components/AgentSurface.test.tsx @@ -1818,7 +1818,7 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Connect' })); expect(onConnectDatoCms).toHaveBeenCalledOnce(); - const onDisconnectDatoCms = vi.fn(); + const onReconnectDatoCms = vi.fn(); rerender( { content: 'Hello', }, ]} - onDisconnectDatoCms={onDisconnectDatoCms} + onReconnectDatoCms={onReconnectDatoCms} onSubmit={vi.fn()} />, ); @@ -1843,11 +1843,11 @@ describe('AgentSurface', () => { expect(screen.queryByText('Remote MCP connection')).not.toBeInTheDocument(); expect(screen.queryByRole('note')).not.toBeInTheDocument(); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); - const disconnectButton = screen.getByRole('button', { - name: 'Disconnect to change access', + const reconnectButton = screen.getByRole('button', { + name: 'Reconnect to change access', }); - await user.click(disconnectButton); - expect(onDisconnectDatoCms).toHaveBeenCalledOnce(); + await user.click(reconnectButton); + expect(onReconnectDatoCms).toHaveBeenCalledOnce(); }); it('shows only six provided recent chats on the connection page', async () => { @@ -2407,7 +2407,7 @@ describe('AgentSurface', () => { pluginReadOnly: true, }} entries={[]} - onDisconnectDatoCms={vi.fn()} + onReconnectDatoCms={vi.fn()} onSubmit={vi.fn()} />, ); @@ -2424,7 +2424,7 @@ describe('AgentSurface', () => { expect(screen.getByText(/Current access level/)).toBeVisible(); // Each connection is its own OAuth client, so reconnecting changes the level. expect( - screen.getByRole('button', { name: 'Disconnect to change access' }), + screen.getByRole('button', { name: 'Reconnect to change access' }), ).toBeVisible(); expect( screen.queryByRole('button', { name: 'Check access again' }), diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx index b35bd426..5f6e577f 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -286,6 +286,8 @@ export type AgentSurfaceProps = { mentionHost?: AgentMentionHost; onConnectDatoCms?: () => void; onDisconnectDatoCms?: () => void; + /** Signs in again so the editor can pick another access level. */ + onReconnectDatoCms?: () => void; onCheckDatoCmsAccess?: () => void; recentConversations?: readonly AgentConversationSummaryViewModel[]; onSelectConversation?: ( @@ -347,6 +349,7 @@ type CredentialPanelProps = Pick< | 'connection' | 'onConnectDatoCms' | 'onDisconnectDatoCms' + | 'onReconnectDatoCms' | 'onCheckDatoCmsAccess' | 'recentConversations' | 'onSelectConversation' @@ -422,10 +425,10 @@ function UnverifiedAccessNotice({ function DatoCmsConnectionField({ connection, onConnectDatoCms, - onDisconnectDatoCms, + onReconnectDatoCms, }: Pick< CredentialPanelProps, - 'connection' | 'onConnectDatoCms' | 'onDisconnectDatoCms' + 'connection' | 'onConnectDatoCms' | 'onReconnectDatoCms' >) { const datoCmsConnected = connection.datoCmsStatus === 'connected'; const datoCmsConnecting = connection.datoCmsStatus === 'connecting'; @@ -443,16 +446,17 @@ function DatoCmsConnectionField({ Connected to {connectedLabel}.

    - {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. )} @@ -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 normally provides. +import { CtxContext } from 'datocms-react-ui/dist/cjs/Canvas'; +import type { ReactElement, ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { MAX_CONVERSATION_MESSAGE_CHARACTERS } from '../lib/conversations'; import type { AgentMentionHost } from '../lib/mentionHost'; @@ -53,6 +56,22 @@ afterEach(() => { scrollIntoView.mockClear(); }); +const stubCtx = { + theme: {}, + cssDesignTokens: {}, + bodyPadding: [0, 0, 0, 0], +} as never; + +/** render() inside the plugin ctx, as the CMS mounts the surface. */ +function render(ui: ReactElement, options?: RenderOptions) { + return baseRender(ui, { + wrapper: ({ children }) => ( + {children} + ), + ...options, + }); +} + const connected: AgentConnectionViewModel = { status: 'connected', statusLabel: 'Ready', @@ -1837,16 +1856,37 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Open chats' })); expect(screen.getByRole('button', { name: 'Back to chat' })).toBeVisible(); - expect(screen.getByRole('heading', { name: 'Agent setup' })).toHaveFocus(); - expect(screen.getByText('Marketing website')).toBeVisible(); - expect(screen.getAllByText('Done')).toHaveLength(2); - expect(screen.queryByText('Remote MCP connection')).not.toBeInTheDocument(); - expect(screen.queryByRole('note')).not.toBeInTheDocument(); + // Once set up, the chats panel is only the chat history. + expect(screen.getByRole('heading', { name: 'Chats' })).toHaveFocus(); + expect( + screen.queryByRole('heading', { name: 'Agent setup' }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Done')).not.toBeInTheDocument(); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); - const reconnectButton = screen.getByRole('button', { - name: 'Reconnect to change access', + + // Reconnecting lives in the "⋮" menu and asks first. + await user.click(screen.getByRole('button', { name: 'Agent options' })); + expect( + screen.getByText( + 'Only users who can edit the schema can change the plugin settings.', + ), + ).toBeVisible(); + await user.click(screen.getByText('Reconnect to change access…')); + const dialog = screen.getByRole('dialog', { + name: 'Reconnect to change access?', }); - await user.click(reconnectButton); + expect(onReconnectDatoCms).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onReconnectDatoCms).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Agent options' })); + await user.click(screen.getByText('Reconnect to change access…')); + await user.click( + within(screen.getByRole('dialog')).getByRole('button', { + name: 'Reconnect', + }), + ); expect(onReconnectDatoCms).toHaveBeenCalledOnce(); }); @@ -2031,7 +2071,7 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Open chats' })); await user.click(screen.getByRole('button', { name: 'New chat' })); - expect(screen.getByRole('heading', { name: 'Agent setup' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Chats' })).toBeVisible(); }); it('shows no chat controls until both setup steps are done', () => { @@ -2398,7 +2438,9 @@ describe('AgentSurface', () => { ).not.toBeInTheDocument(); }); - it('shows OAuth access separately from plugin Read Only and explains how to change it', async () => { + it('shows the DatoCMS access level and plugin Read Only in the options menu', async () => { + const user = userEvent.setup(); + const onOpenPluginSettings = vi.fn(); render( { pluginReadOnly: true, }} entries={[]} + onOpenPluginSettings={onOpenPluginSettings} onReconnectDatoCms={vi.fn()} onSubmit={vi.fn()} />, ); - await userEvent.click(screen.getByRole('button', { name: 'Open chats' })); + await user.click(screen.getByRole('button', { name: 'Agent options' })); expect(screen.getByText('Read and edit content')).toBeVisible(); expect( - screen.getByText( - 'Plugin Read Only is enabled. All agent writes are disabled.', - ), - ).toBeVisible(); - expect( - screen.getByText(/Schema and management changes are unavailable/), + screen.getByText('Plugin Read Only is on, so the agent changes nothing.'), ).toBeVisible(); - expect(screen.getByText(/Current access level/)).toBeVisible(); - // Each connection is its own OAuth client, so reconnecting changes the level. - expect( - screen.getByRole('button', { name: 'Reconnect to change access' }), - ).toBeVisible(); - expect( - screen.queryByRole('button', { name: 'Check access again' }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'Account settings' }), - ).not.toBeInTheDocument(); + expect(screen.getByText('Reconnect to change access…')).toBeVisible(); + await user.click(screen.getByText('Plugin settings')); + expect(onOpenPluginSettings).toHaveBeenCalledOnce(); }); }); diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx index 5f6e577f..e47e5caa 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -1,6 +1,11 @@ import { BackIcon, Button, + Dropdown, + DropdownMenu, + DropdownOption, + DropdownSeparator, + DropdownText, FieldError, Spinner, TextareaInput, @@ -39,6 +44,7 @@ import { CopyIcon, EditIcon, HistoryIcon, + MoreIcon, PlusIcon, RetryIcon, WarningIcon, @@ -425,11 +431,7 @@ function UnverifiedAccessNotice({ function DatoCmsConnectionField({ connection, onConnectDatoCms, - onReconnectDatoCms, -}: Pick< - CredentialPanelProps, - 'connection' | 'onConnectDatoCms' | 'onReconnectDatoCms' ->) { +}: Pick) { const datoCmsConnected = connection.datoCmsStatus === 'connected'; const datoCmsConnecting = connection.datoCmsStatus === 'connecting'; const accountLabel = connection.datoCmsAccountLabel?.trim(); @@ -446,19 +448,6 @@ function DatoCmsConnectionField({ Connected to {connectedLabel}.

    - {onReconnectDatoCms && ( - // Each connection is its own OAuth client with its own access level, - // so a fresh sign-in is how an editor picks a different one. - - )} ) : ( <> @@ -515,11 +504,14 @@ function DatoCmsConnectionField({ function RecentChats({ conversations, disabled, + headingRef, onSelectConversation, onStartNewChat, }: { conversations: readonly AgentConversationSummaryViewModel[]; disabled?: boolean; + /** Takes the focus when the chats panel opens. */ + headingRef?: RefObject; onSelectConversation?: ( conversation: AgentConversationSummaryViewModel, ) => void; @@ -531,7 +523,8 @@ function RecentChats({ DEFAULT_MAX_CONVERSATIONS, ); - if (visibleConversations.length === 0 && !onStartNewChat) { + // The chats panel always shows its heading; inside setup the list can hide. + if (visibleConversations.length === 0 && !onStartNewChat && !headingRef) { return null; } @@ -540,7 +533,13 @@ function RecentChats({
    -

    Chats

    +

    + Chats +

    {onStartNewChat && ( @@ -622,6 +621,24 @@ function CredentialPanel({ const isDatoCmsReady = connection.datoCmsStatus === 'connected'; const isSetupComplete = isProviderReady && isDatoCmsReady; + // Once set up, the chats panel is only the chat history: the access level and + // the settings live in the toolbar's "⋮" menu, out of the way. + if (isSettings) { + return ( +
    + + + +
    + ); + } + return (
    @@ -2180,6 +2196,164 @@ function Transcript({ ); } +/** One line on what the current DatoCMS access lets the agent do. */ +function accessSummary(connection: AgentConnectionViewModel): string { + if (connection.pluginReadOnly) + return 'Plugin Read Only is on, so the agent changes nothing.'; + switch (connection.oauthAccessLevel) { + case 'content_view_only': + return 'It can read content, but not change it.'; + case 'content_only': + return 'It can edit content, but not the schema.'; + case 'unrestricted': + return 'It can do anything your role allows.'; + default: + return 'Not verified yet.'; + } +} + +/** + * The "⋮" menu of the chat toolbar: plugin settings, and the DatoCMS access. + * Reconnecting asks inside the menu, not in a host dialog: the sign-in popup + * opens only from a click in this frame, so the confirming click must be here. + */ +function AgentOptionsMenu({ + connection, + disabled, + onOpenPluginSettings, + onReconnectDatoCms, +}: { + connection: AgentConnectionViewModel; + disabled: boolean; + onOpenPluginSettings?: () => void; + onReconnectDatoCms?: () => void; +}) { + const [isConfirmingReconnect, setIsConfirmingReconnect] = useState(false); + const accessLevel = connection.oauthAccessLevel ?? 'unknown'; + + return ( + <> + ( + + )} + > + + + Plugin settings + + {!onOpenPluginSettings && ( + + Only users who can edit the schema can change the plugin settings. + + )} + + + + DatoCMS access: {mcpAccessLabel(accessLevel)} + {accessSummary(connection)} + + + {onReconnectDatoCms && ( + setIsConfirmingReconnect(true)}> + Reconnect to change access… + + )} + + + {isConfirmingReconnect && onReconnectDatoCms && ( + setIsConfirmingReconnect(false)} + onConfirm={() => { + setIsConfirmingReconnect(false); + onReconnectDatoCms(); + }} + /> + )} + + ); +} + +/** + * Asks before reconnecting. It lives in this frame, not in a host dialog: the + * sign-in popup opens only from a click in this frame. + */ +function ReconnectDialog({ + onCancel, + onConfirm, +}: { + onCancel: () => void; + onConfirm: () => void; +}) { + const titleId = useId(); + const dialogRef = useRef(null); + useEffect(() => { + // The kit's Button takes no ref, so focus Reconnect through the dialog. + dialogRef.current + ?.querySelector( + '[data-reconnect-confirm] button, button[data-reconnect-confirm]', + ) + ?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onCancel(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onCancel]); + + return ( +
    +
    +

    Reconnect to change access?

    +

    + You'll sign in to DatoCMS again and pick a new access level on its + consent screen. Your current connection ends when you finish. +

    +
    + + + + +
    +
    +
    + ); +} + /** Icon and text of the auto-approve toggle: a slashed bolt when off, a danger badge when on. */ function AutoApproveLabel({ isChanging, @@ -2209,6 +2383,9 @@ function AutoApproveLabel({ } function UtilityControls({ + connection, + onOpenPluginSettings, + onReconnectDatoCms, connected, chatsOpen, autoApproveEnabled, @@ -2220,6 +2397,9 @@ function UtilityControls({ onCloseChats, onOpenChats, }: { + connection: AgentConnectionViewModel; + onOpenPluginSettings?: () => void; + onReconnectDatoCms?: () => void; connected: boolean; chatsOpen: boolean; autoApproveEnabled: boolean; @@ -2267,28 +2447,36 @@ function UtilityControls({ )} - {!chatsOpen && ( - - - - - {autoApproveLabel} - - )} +
    + {!chatsOpen && ( + + + + + {autoApproveLabel} + + )} + +
    {autoApproveError && !chatsOpen && ( {autoApproveError} @@ -2475,6 +2663,9 @@ export function AgentSurface({ return (
    Date: Wed, 4 Nov 2026 16:50:47 -0800 Subject: [PATCH 11/17] Dato Agent: cover the Agent tab's chat when it is too narrow to use The CMS owns the Agent tab's divider: the plugin SDK only sets a starting width. Below 200px the chat can't be used, so the tab shows "Sidebar too narrow. Drag its edge wider to see the Dato Agent." The chat stays mounted underneath (inert), so a reply in progress survives and continues once the tab is wider again. The record sidebar is unaffected: the CMS already keeps it at least 300px wide. Co-Authored-By: Claude Opus 5.5 --- .../src/entrypoints/AgentInspector.module.css | 23 +++++ dato-agent/src/entrypoints/AgentInspector.tsx | 85 ++++++++++++------- .../src/entrypoints/ContextSurfaces.test.tsx | 29 +++++++ 3 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 dato-agent/src/entrypoints/AgentInspector.module.css diff --git a/dato-agent/src/entrypoints/AgentInspector.module.css b/dato-agent/src/entrypoints/AgentInspector.module.css new file mode 100644 index 00000000..03b93471 --- /dev/null +++ b/dato-agent/src/entrypoints/AgentInspector.module.css @@ -0,0 +1,23 @@ +.frame { + height: 100%; +} + +.tooNarrow { + position: fixed; + z-index: 30; + display: grid; + inset: 0; + align-content: center; + justify-items: center; + gap: var(--spacing-s); + padding: var(--spacing-m); + color: var(--color--ink-subtle); + background: var(--color--surface); + font-size: var(--font-size-xs); + text-align: center; +} + +.tooNarrow strong { + color: var(--color--ink); + font-size: var(--font-size-m); +} diff --git a/dato-agent/src/entrypoints/AgentInspector.tsx b/dato-agent/src/entrypoints/AgentInspector.tsx index 929bb3dd..a0869781 100644 --- a/dato-agent/src/entrypoints/AgentInspector.tsx +++ b/dato-agent/src/entrypoints/AgentInspector.tsx @@ -1,5 +1,5 @@ import type { Field, ItemType, RenderInspectorCtx } from 'datocms-plugin-sdk'; -import { Canvas } from 'datocms-react-ui'; +import { Canvas, useMediaQuery } from 'datocms-react-ui'; import { useCallback, useEffect, useRef } from 'react'; import { openApprovalDetailsModal } from '../lib/approvalDetailsModal'; import { confirmEnableAutoApproval } from '../lib/autoApproval'; @@ -16,6 +16,7 @@ import { } from '../lib/navigation'; import { canRoleUseDatoAgent } from '../lib/permissions'; import AgentFrame from './AgentFrame'; +import styles from './AgentInspector.module.css'; import AgentUnavailableFrame from './AgentUnavailableFrame'; type Props = { @@ -267,44 +268,64 @@ function AuthorizedAgentInspector({ ctx }: Props) { 'project', ].join(':'); const mentionHost = createAgentMentionHost(ctx); + const isTooNarrow = useMediaQuery( + `(max-width: ${MIN_INSPECTOR_CHAT_WIDTH - 1}px)`, + ).matches; return ( - void ctx.navigateTo(pluginSettingsPath(ctx, ctx.plugin.id)) - : undefined - } - siteId={ctx.site.id} - siteName={ctx.site.attributes.name} - environment={ctx.environment} - isEnvironmentPrimary={ctx.isEnvironmentPrimary} - currentUserId={ctx.currentUser.id} - surface="project" - scope={{ type: 'project' }} - navigator={createInspectorNavigator(ctx)} - mentionHost={mentionHost} - config={normalizeConfig(ctx.plugin.attributes.parameters)} - loadHostContext={loadHostContext} - getModelSchema={async ({ identifier, cursor }) => ({ - schema: (await schemaResolver(identifier, cursor)).text, - })} - onReviewApprovalDetails={(approval) => - openApprovalDetailsModal(ctx, approval) - } - onConfirmEnableAutoApprove={() => - confirmEnableAutoApproval(ctx, { - siteName: ctx.site.attributes.name, - }) - } - /> + {isTooNarrow && ( +
    + Sidebar too narrow + Drag its edge wider to see the Dato Agent. +
    + )} + {/* The chat stays mounted under the notice, so a reply in progress survives. */} +
    + + void ctx.navigateTo(pluginSettingsPath(ctx, ctx.plugin.id)) + : undefined + } + siteId={ctx.site.id} + siteName={ctx.site.attributes.name} + environment={ctx.environment} + isEnvironmentPrimary={ctx.isEnvironmentPrimary} + currentUserId={ctx.currentUser.id} + surface="project" + scope={{ type: 'project' }} + navigator={createInspectorNavigator(ctx)} + mentionHost={mentionHost} + config={normalizeConfig(ctx.plugin.attributes.parameters)} + loadHostContext={loadHostContext} + getModelSchema={async ({ identifier, cursor }) => ({ + schema: (await schemaResolver(identifier, cursor)).text, + })} + onReviewApprovalDetails={(approval) => + openApprovalDetailsModal(ctx, approval) + } + onConfirmEnableAutoApprove={() => + confirmEnableAutoApproval(ctx, { + siteName: ctx.site.attributes.name, + }) + } + /> +
    ); } +/** + * Below this frame width the Agent tab's chat is too cramped to use. The CMS + * owns the divider (the plugin SDK only sets a starting width), so the tab + * covers the chat with a notice instead. + */ +export const MIN_INSPECTOR_CHAT_WIDTH = 200; + export default function AgentInspector({ ctx }: Props) { const config = normalizeConfig(ctx.plugin.attributes.parameters); diff --git a/dato-agent/src/entrypoints/ContextSurfaces.test.tsx b/dato-agent/src/entrypoints/ContextSurfaces.test.tsx index e3675f5b..617867a6 100644 --- a/dato-agent/src/entrypoints/ContextSurfaces.test.tsx +++ b/dato-agent/src/entrypoints/ContextSurfaces.test.tsx @@ -18,8 +18,11 @@ const frameMocks = vi.hoisted(() => ({ unmountCount: 0, })); +const mediaQuery = vi.hoisted(() => ({ matches: false })); + vi.mock('datocms-react-ui', () => ({ Canvas: ({ children }: { children: ReactNode }) => <>{children}, + useMediaQuery: () => ({ matches: mediaQuery.matches }), })); vi.mock('../lib/persistedWidth', () => ({ @@ -629,6 +632,32 @@ describe('AgentInspector host context', () => { }, ); + it('covers a too-narrow Agent tab with a notice and keeps the chat mounted', () => { + mediaQuery.matches = true; + try { + const article = model({ + id: 'article-id', + apiKey: 'article', + fieldIds: [], + }); + const { container } = render( + []), + })} + />, + ); + expect(screen.getByText('Sidebar too narrow')).toBeVisible(); + // Unmounting the chat would drop a reply in progress; it only goes inert. + expect(frameMocks.mountCount).toBe(1); + expect(container.querySelector('[inert]')).not.toBeNull(); + } finally { + mediaQuery.matches = false; + } + }); + it('keeps the project frame mounted while highlight and location change', async () => { const article = model({ id: 'article-id', From bf426764154cd01f0ea2ca3cc00bd99f362b26d3 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:55:03 -0800 Subject: [PATCH 12/17] Dato Agent: start the Agent tab's chat at 300px, and cover it below that One constant, AGENT_PANE_WIDTH = 300, now sets both the starting width of the Agent tab's chat (it was 420) and the narrowest it stays usable: at 299px and below, the tab shows the "too narrow" notice. On a 1440px window the chat frame measures exactly 300px. Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/entrypoints/AgentInspector.tsx | 10 ++-------- dato-agent/src/lib/inspectorLayout.test.ts | 12 ++++++------ dato-agent/src/lib/inspectorLayout.ts | 8 ++++++-- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/dato-agent/src/entrypoints/AgentInspector.tsx b/dato-agent/src/entrypoints/AgentInspector.tsx index a0869781..31e17c7f 100644 --- a/dato-agent/src/entrypoints/AgentInspector.tsx +++ b/dato-agent/src/entrypoints/AgentInspector.tsx @@ -9,6 +9,7 @@ import { createModelSchemaResolver, } from '../lib/hostContext'; import { resolveFieldsWithinDeadline } from '../lib/hostContextDeadline'; +import { AGENT_PANE_WIDTH } from '../lib/inspectorLayout'; import { createAgentMentionHost } from '../lib/mentionHost'; import { createInspectorNavigator, @@ -269,7 +270,7 @@ function AuthorizedAgentInspector({ ctx }: Props) { ].join(':'); const mentionHost = createAgentMentionHost(ctx); const isTooNarrow = useMediaQuery( - `(max-width: ${MIN_INSPECTOR_CHAT_WIDTH - 1}px)`, + `(max-width: ${AGENT_PANE_WIDTH - 1}px)`, ).matches; return ( @@ -319,13 +320,6 @@ function AuthorizedAgentInspector({ ctx }: Props) { ); } -/** - * Below this frame width the Agent tab's chat is too cramped to use. The CMS - * owns the divider (the plugin SDK only sets a starting width), so the tab - * covers the chat with a notice instead. - */ -export const MIN_INSPECTOR_CHAT_WIDTH = 200; - export default function AgentInspector({ ctx }: Props) { const config = normalizeConfig(ctx.plugin.attributes.parameters); diff --git a/dato-agent/src/lib/inspectorLayout.test.ts b/dato-agent/src/lib/inspectorLayout.test.ts index c7660dc8..249b4798 100644 --- a/dato-agent/src/lib/inspectorLayout.test.ts +++ b/dato-agent/src/lib/inspectorLayout.test.ts @@ -3,11 +3,11 @@ import { inspectorRecordPaneWidth } from './inspectorLayout'; describe('inspectorRecordPaneWidth', () => { it.each([ - [1710, 1290], - [1440, 1020], - [1280, 860], + [1710, 1410], + [1440, 1140], + [1280, 980], ])( - 'keeps a compact 420px agent beside a %ipx browser', + 'starts the agent at 300px beside a %ipx browser', (browserWidth, expected) => { expect(inspectorRecordPaneWidth(browserWidth)).toBe(expected); }, @@ -18,7 +18,7 @@ describe('inspectorRecordPaneWidth', () => { }); it('uses a desktop fallback when browser sizing is unavailable', () => { - expect(inspectorRecordPaneWidth(undefined)).toBe(1020); - expect(inspectorRecordPaneWidth(Number.NaN)).toBe(1020); + expect(inspectorRecordPaneWidth(undefined)).toBe(1140); + expect(inspectorRecordPaneWidth(Number.NaN)).toBe(1140); }); }); diff --git a/dato-agent/src/lib/inspectorLayout.ts b/dato-agent/src/lib/inspectorLayout.ts index b2335eda..7a18d90e 100644 --- a/dato-agent/src/lib/inspectorLayout.ts +++ b/dato-agent/src/lib/inspectorLayout.ts @@ -1,6 +1,10 @@ const FALLBACK_BROWSER_WIDTH = 1440; const MINIMUM_RECORD_PANE_WIDTH = 550; -const TARGET_AGENT_PANE_WIDTH = 420; +/** + * The Agent tab's starting chat width, and the narrowest it stays usable: below + * it, the tab covers the chat with a "too narrow" notice. + */ +export const AGENT_PANE_WIDTH = 300; export function inspectorRecordPaneWidth( browserOuterWidth: number | undefined, @@ -14,6 +18,6 @@ export function inspectorRecordPaneWidth( return Math.max( MINIMUM_RECORD_PANE_WIDTH, - Math.round(browserWidth - TARGET_AGENT_PANE_WIDTH), + Math.round(browserWidth - AGENT_PANE_WIDTH), ); } From 941a4b46ad5f0b9d967ff38232b434d020483bf7 Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:56:09 -0800 Subject: [PATCH 13/17] Dato Agent: clearer names in the options menu "AI settings" instead of "Plugin settings", "Current DatoCMS access level", and summaries that start with "The agent can". Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/components/AgentSurface.test.tsx | 8 +++++--- dato-agent/src/components/AgentSurface.tsx | 15 ++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx index 5e140392..aa76797a 100644 --- a/dato-agent/src/components/AgentSurface.test.tsx +++ b/dato-agent/src/components/AgentSurface.test.tsx @@ -1868,7 +1868,7 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Agent options' })); expect( screen.getByText( - 'Only users who can edit the schema can change the plugin settings.', + 'Only users who can edit the schema can change the AI settings.', ), ).toBeVisible(); await user.click(screen.getByText('Reconnect to change access…')); @@ -2457,10 +2457,12 @@ describe('AgentSurface', () => { await user.click(screen.getByRole('button', { name: 'Agent options' })); expect(screen.getByText('Read and edit content')).toBeVisible(); expect( - screen.getByText('Plugin Read Only is on, so the agent changes nothing.'), + screen.getByText( + 'Plugin Read Only is on, so the agent can change nothing.', + ), ).toBeVisible(); expect(screen.getByText('Reconnect to change access…')).toBeVisible(); - await user.click(screen.getByText('Plugin settings')); + await user.click(screen.getByText('AI settings')); expect(onOpenPluginSettings).toHaveBeenCalledOnce(); }); }); diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx index e47e5caa..4dba7f87 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -2199,14 +2199,14 @@ function Transcript({ /** One line on what the current DatoCMS access lets the agent do. */ function accessSummary(connection: AgentConnectionViewModel): string { if (connection.pluginReadOnly) - return 'Plugin Read Only is on, so the agent changes nothing.'; + return 'Plugin Read Only is on, so the agent can change nothing.'; switch (connection.oauthAccessLevel) { case 'content_view_only': - return 'It can read content, but not change it.'; + return 'The agent can read content, but not change it.'; case 'content_only': - return 'It can edit content, but not the schema.'; + return 'The agent can edit content, but not the schema.'; case 'unrestricted': - return 'It can do anything your role allows.'; + return 'The agent can do anything your role allows.'; default: return 'Not verified yet.'; } @@ -2254,17 +2254,18 @@ function AgentOptionsMenu({ disabled={!onOpenPluginSettings} onClick={onOpenPluginSettings} > - Plugin settings + AI settings {!onOpenPluginSettings && ( - Only users who can edit the schema can change the plugin settings. + Only users who can edit the schema can change the AI settings. )} - DatoCMS access: {mcpAccessLabel(accessLevel)} + Current DatoCMS access level:{' '} + {mcpAccessLabel(accessLevel)} {accessSummary(connection)} From ece1467cd1f0cf77cb3b13a046baaec97e986a3c Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 16:59:44 -0800 Subject: [PATCH 14/17] Dato Agent: remember the Agent tab's chat width per project The record sidebar already remembers its width in localStorage. The Agent tab now does too: the chat saves its width on every resize (per project, so projects don't share it), and the tab's starting split leaves that width to the chat. A width under 300px isn't saved, so the default comes back. The plugin SDK has no per-user server-side storage, and plugin parameters are shared by the whole project, so this stays per browser. Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/entrypoints/AgentInspector.tsx | 6 ++- dato-agent/src/lib/inspectorLayout.test.ts | 51 ++++++++++++++++++- dato-agent/src/lib/inspectorLayout.ts | 49 +++++++++++++++++- dato-agent/src/main.tsx | 10 +++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/dato-agent/src/entrypoints/AgentInspector.tsx b/dato-agent/src/entrypoints/AgentInspector.tsx index 31e17c7f..be00d8e5 100644 --- a/dato-agent/src/entrypoints/AgentInspector.tsx +++ b/dato-agent/src/entrypoints/AgentInspector.tsx @@ -9,7 +9,10 @@ import { createModelSchemaResolver, } from '../lib/hostContext'; import { resolveFieldsWithinDeadline } from '../lib/hostContextDeadline'; -import { AGENT_PANE_WIDTH } from '../lib/inspectorLayout'; +import { + AGENT_PANE_WIDTH, + useRememberedAgentPaneWidth, +} from '../lib/inspectorLayout'; import { createAgentMentionHost } from '../lib/mentionHost'; import { createInspectorNavigator, @@ -269,6 +272,7 @@ function AuthorizedAgentInspector({ ctx }: Props) { 'project', ].join(':'); const mentionHost = createAgentMentionHost(ctx); + useRememberedAgentPaneWidth(ctx.site.id); const isTooNarrow = useMediaQuery( `(max-width: ${AGENT_PANE_WIDTH - 1}px)`, ).matches; diff --git a/dato-agent/src/lib/inspectorLayout.test.ts b/dato-agent/src/lib/inspectorLayout.test.ts index 249b4798..f436c654 100644 --- a/dato-agent/src/lib/inspectorLayout.test.ts +++ b/dato-agent/src/lib/inspectorLayout.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest'; -import { inspectorRecordPaneWidth } from './inspectorLayout'; +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + inspectorRecordPaneWidth, + readAgentPaneWidth, + useRememberedAgentPaneWidth, +} from './inspectorLayout'; describe('inspectorRecordPaneWidth', () => { it.each([ @@ -22,3 +27,45 @@ describe('inspectorRecordPaneWidth', () => { expect(inspectorRecordPaneWidth(Number.NaN)).toBe(1140); }); }); + +describe('remembered Agent tab width', () => { + const resizeTo = ({ inner, outer }: { inner: number; outer: number }) => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: inner, + }); + Object.defineProperty(window, 'outerWidth', { + configurable: true, + value: outer, + }); + window.dispatchEvent(new Event('resize')); + }; + + afterEach(() => localStorage.clear()); + + it('leaves the remembered width to the chat when the tab opens', () => { + expect(inspectorRecordPaneWidth(1440, 500)).toBe(940); + }); + + it('remembers the latest width per project', () => { + resizeTo({ inner: 300, outer: 1440 }); + const { unmount } = renderHook(() => useRememberedAgentPaneWidth('site-1')); + + resizeTo({ inner: 520, outer: 1440 }); + expect(readAgentPaneWidth('site-1')).toBe(520); + expect(readAgentPaneWidth('site-2')).toBeUndefined(); + + // Some browsers report the iframe's own size as outerWidth during a drag. + resizeTo({ inner: 610, outer: 610 }); + expect(readAgentPaneWidth('site-1')).toBe(610); + unmount(); + }); + + it('does not remember a width too narrow to use', () => { + resizeTo({ inner: 300, outer: 1440 }); + const { unmount } = renderHook(() => useRememberedAgentPaneWidth('site-1')); + resizeTo({ inner: 180, outer: 1440 }); + expect(readAgentPaneWidth('site-1')).toBeUndefined(); + unmount(); + }); +}); diff --git a/dato-agent/src/lib/inspectorLayout.ts b/dato-agent/src/lib/inspectorLayout.ts index 7a18d90e..0c0e4021 100644 --- a/dato-agent/src/lib/inspectorLayout.ts +++ b/dato-agent/src/lib/inspectorLayout.ts @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + const FALLBACK_BROWSER_WIDTH = 1440; const MINIMUM_RECORD_PANE_WIDTH = 550; /** @@ -6,8 +8,10 @@ const MINIMUM_RECORD_PANE_WIDTH = 550; */ export const AGENT_PANE_WIDTH = 300; +/** Record pane width for the Agent tab, leaving `agentWidth` for the chat. */ export function inspectorRecordPaneWidth( browserOuterWidth: number | undefined, + agentWidth: number = AGENT_PANE_WIDTH, ): number { const browserWidth = typeof browserOuterWidth === 'number' && @@ -18,6 +22,49 @@ export function inspectorRecordPaneWidth( return Math.max( MINIMUM_RECORD_PANE_WIDTH, - Math.round(browserWidth - AGENT_PANE_WIDTH), + Math.round(browserWidth - agentWidth), ); } + +/** Per project: the plugin's origin is shared by every project this browser opens. */ +const agentWidthKey = (siteId: string) => + `dato-agent:inspector-width:${siteId}`; + +/** The chat width this editor last dragged the Agent tab to, in this browser. */ +export function readAgentPaneWidth(siteId: string): number | undefined { + try { + const width = Number.parseInt( + localStorage.getItem(agentWidthKey(siteId)) ?? '', + 10, + ); + return Number.isFinite(width) && width >= AGENT_PANE_WIDTH + ? width + : undefined; + } catch { + return undefined; + } +} + +/** + * Remembers the Agent tab's chat width whenever it changes, like the record + * sidebar does. A width too narrow to use isn't saved, so the default comes + * back instead. (Telling a drag from a window resize by `outerWidth` fails in + * browsers that report the iframe's own size there, which lost every drag.) + */ +export function useRememberedAgentPaneWidth(siteId: string): void { + useEffect(() => { + const remember = () => { + if (window.innerWidth < AGENT_PANE_WIDTH) return; + try { + localStorage.setItem( + agentWidthKey(siteId), + String(Math.round(window.innerWidth)), + ); + } catch { + // Dragging still works when iframe storage is blocked. + } + }; + window.addEventListener('resize', remember); + return () => window.removeEventListener('resize', remember); + }, [siteId]); +} diff --git a/dato-agent/src/main.tsx b/dato-agent/src/main.tsx index 08592350..30ef4766 100644 --- a/dato-agent/src/main.tsx +++ b/dato-agent/src/main.tsx @@ -12,7 +12,10 @@ import OAuthCallbackPage from './entrypoints/OAuthCallbackPage'; import { APPROVAL_DETAILS_MODAL_ID } from './lib/approvalDetailsModal'; import { normalizeConfig } from './lib/config'; import { FILE_DETAILS_MODAL_ID } from './lib/fileDetailsModal'; -import { inspectorRecordPaneWidth } from './lib/inspectorLayout'; +import { + inspectorRecordPaneWidth, + readAgentPaneWidth, +} from './lib/inspectorLayout'; import { handleOAuthCallbackIfPresent } from './lib/oauth'; import { canRoleUseDatoAgent } from './lib/permissions'; import { DEFAULT_SIDEBAR_WIDTH, readSidebarWidth } from './lib/persistedWidth'; @@ -60,7 +63,10 @@ if (!handledOAuthCallback) { // Inspector tabs render the CMS record in the right-hand secondary // pane, so this value deliberately reserves most of the viewport // for the record and leaves the agent as a compact companion. - preferredWidth: inspectorRecordPaneWidth(window.outerWidth), + preferredWidth: inspectorRecordPaneWidth( + window.outerWidth, + readAgentPaneWidth(ctx.site.id), + ), initialInspectorPanel: { panelId: 'dato-agent-empty', }, From c60e8e325b2d243d4146200550f2c3644c88fa8c Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 17:02:54 -0800 Subject: [PATCH 15/17] Dato Agent: remember the Agent tab width per browser, across projects An editor wants their chat width in every project. A collaborator's user ID differs per project, so keying by it would stay per-project; a single key in the plugin's localStorage gives each browser profile, which belongs to one person, one width everywhere. Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/entrypoints/AgentInspector.tsx | 2 +- dato-agent/src/lib/inspectorLayout.test.ts | 13 ++++++------- dato-agent/src/lib/inspectorLayout.ts | 19 +++++++++++-------- dato-agent/src/main.tsx | 2 +- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/dato-agent/src/entrypoints/AgentInspector.tsx b/dato-agent/src/entrypoints/AgentInspector.tsx index be00d8e5..cdccdd76 100644 --- a/dato-agent/src/entrypoints/AgentInspector.tsx +++ b/dato-agent/src/entrypoints/AgentInspector.tsx @@ -272,7 +272,7 @@ function AuthorizedAgentInspector({ ctx }: Props) { 'project', ].join(':'); const mentionHost = createAgentMentionHost(ctx); - useRememberedAgentPaneWidth(ctx.site.id); + useRememberedAgentPaneWidth(); const isTooNarrow = useMediaQuery( `(max-width: ${AGENT_PANE_WIDTH - 1}px)`, ).matches; diff --git a/dato-agent/src/lib/inspectorLayout.test.ts b/dato-agent/src/lib/inspectorLayout.test.ts index f436c654..f05156b4 100644 --- a/dato-agent/src/lib/inspectorLayout.test.ts +++ b/dato-agent/src/lib/inspectorLayout.test.ts @@ -47,25 +47,24 @@ describe('remembered Agent tab width', () => { expect(inspectorRecordPaneWidth(1440, 500)).toBe(940); }); - it('remembers the latest width per project', () => { + it('remembers the latest width for every project in this browser', () => { resizeTo({ inner: 300, outer: 1440 }); - const { unmount } = renderHook(() => useRememberedAgentPaneWidth('site-1')); + const { unmount } = renderHook(() => useRememberedAgentPaneWidth()); resizeTo({ inner: 520, outer: 1440 }); - expect(readAgentPaneWidth('site-1')).toBe(520); - expect(readAgentPaneWidth('site-2')).toBeUndefined(); + expect(readAgentPaneWidth()).toBe(520); // Some browsers report the iframe's own size as outerWidth during a drag. resizeTo({ inner: 610, outer: 610 }); - expect(readAgentPaneWidth('site-1')).toBe(610); + expect(readAgentPaneWidth()).toBe(610); unmount(); }); it('does not remember a width too narrow to use', () => { resizeTo({ inner: 300, outer: 1440 }); - const { unmount } = renderHook(() => useRememberedAgentPaneWidth('site-1')); + const { unmount } = renderHook(() => useRememberedAgentPaneWidth()); resizeTo({ inner: 180, outer: 1440 }); - expect(readAgentPaneWidth('site-1')).toBeUndefined(); + expect(readAgentPaneWidth()).toBeUndefined(); unmount(); }); }); diff --git a/dato-agent/src/lib/inspectorLayout.ts b/dato-agent/src/lib/inspectorLayout.ts index 0c0e4021..7774f87f 100644 --- a/dato-agent/src/lib/inspectorLayout.ts +++ b/dato-agent/src/lib/inspectorLayout.ts @@ -26,15 +26,18 @@ export function inspectorRecordPaneWidth( ); } -/** Per project: the plugin's origin is shared by every project this browser opens. */ -const agentWidthKey = (siteId: string) => - `dato-agent:inspector-width:${siteId}`; +/** + * One width per browser, so an editor gets it in every project. Not keyed by + * user ID: a collaborator's ID differs in each project, which would make it + * per-project again. A browser profile already belongs to one person. + */ +const AGENT_WIDTH_KEY = 'dato-agent:inspector-width'; /** The chat width this editor last dragged the Agent tab to, in this browser. */ -export function readAgentPaneWidth(siteId: string): number | undefined { +export function readAgentPaneWidth(): number | undefined { try { const width = Number.parseInt( - localStorage.getItem(agentWidthKey(siteId)) ?? '', + localStorage.getItem(AGENT_WIDTH_KEY) ?? '', 10, ); return Number.isFinite(width) && width >= AGENT_PANE_WIDTH @@ -51,13 +54,13 @@ export function readAgentPaneWidth(siteId: string): number | undefined { * back instead. (Telling a drag from a window resize by `outerWidth` fails in * browsers that report the iframe's own size there, which lost every drag.) */ -export function useRememberedAgentPaneWidth(siteId: string): void { +export function useRememberedAgentPaneWidth(): void { useEffect(() => { const remember = () => { if (window.innerWidth < AGENT_PANE_WIDTH) return; try { localStorage.setItem( - agentWidthKey(siteId), + AGENT_WIDTH_KEY, String(Math.round(window.innerWidth)), ); } catch { @@ -66,5 +69,5 @@ export function useRememberedAgentPaneWidth(siteId: string): void { }; window.addEventListener('resize', remember); return () => window.removeEventListener('resize', remember); - }, [siteId]); + }, []); } diff --git a/dato-agent/src/main.tsx b/dato-agent/src/main.tsx index 30ef4766..453ebd4d 100644 --- a/dato-agent/src/main.tsx +++ b/dato-agent/src/main.tsx @@ -65,7 +65,7 @@ if (!handledOAuthCallback) { // for the record and leaves the agent as a compact companion. preferredWidth: inspectorRecordPaneWidth( window.outerWidth, - readAgentPaneWidth(ctx.site.id), + readAgentPaneWidth(), ), initialInspectorPanel: { panelId: 'dato-agent-empty', From 4acdc911ef8043571cce1f2707eafe0e5056994c Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 17:19:11 -0800 Subject: [PATCH 16/17] Dato Agent: hint at the record pane and cap the chat's line length From #165 (Rodrigo's "usable starting width" PR), without its starting-width logic, which the draggable, remembered width replaces: - The empty record pane adds "Ask the agent to open a record and it appears in this pane. Drag the divider to resize it." - The transcript and composer stop growing at 760px and stay centred, so a wide chat doesn't make very long lines. Co-authored-by: Rodrigo Barona Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/components/AgentSurface.module.css | 4 ++-- dato-agent/src/entrypoints/InspectorEmptyPanel.module.css | 6 ++++++ dato-agent/src/entrypoints/InspectorEmptyPanel.tsx | 4 ++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css index be0d0458..329c6ce0 100644 --- a/dato-agent/src/components/AgentSurface.module.css +++ b/dato-agent/src/components/AgentSurface.module.css @@ -641,7 +641,7 @@ .transcriptInner { box-sizing: border-box; - width: 100%; + width: min(100%, 760px); min-height: 100%; margin: 0 auto; padding: 14px 14px 18px; @@ -1326,7 +1326,7 @@ .composerInner { box-sizing: border-box; - width: 100%; + width: min(100%, 760px); margin: 0 auto; padding: 10px 14px 12px; } diff --git a/dato-agent/src/entrypoints/InspectorEmptyPanel.module.css b/dato-agent/src/entrypoints/InspectorEmptyPanel.module.css index 66faf449..12d48e81 100644 --- a/dato-agent/src/entrypoints/InspectorEmptyPanel.module.css +++ b/dato-agent/src/entrypoints/InspectorEmptyPanel.module.css @@ -10,7 +10,13 @@ } .empty p { + max-width: 36ch; margin: 0; color: var(--color--ink-subtle); font-size: var(--font-size-m); } + +.empty p + p { + margin-top: var(--spacing-s); + font-size: var(--font-size-s); +} diff --git a/dato-agent/src/entrypoints/InspectorEmptyPanel.tsx b/dato-agent/src/entrypoints/InspectorEmptyPanel.tsx index 9abd1313..b2dd0e6f 100644 --- a/dato-agent/src/entrypoints/InspectorEmptyPanel.tsx +++ b/dato-agent/src/entrypoints/InspectorEmptyPanel.tsx @@ -11,6 +11,10 @@ export default function InspectorEmptyPanel({ ctx }: Props) {

    Records will open here

    +

    + Ask the agent to open a record and it appears in this pane. Drag the + divider to resize it. +

    ); From 0b14c8f0506e64d53b5079d60e447be91cf0449c Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Wed, 4 Nov 2026 17:21:04 -0800 Subject: [PATCH 17/17] Dato Agent: keep the Agent tab's record list filled, so Back never spins The record editor's back arrow in the Agent tab switches the right pane to the inspector's record list. The CMS shows an empty list as a loading spinner that never ends, and the plugin only filled that list for "show records", not when it opened a single record. The inspector navigator now keeps the list filled: the last shown list, plus every record the chat opened (newest first, up to 50), sent before the editor opens. Back now lands on "Records opened from the chat". Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/lib/navigation.test.ts | 25 +++++++++++++++++++++++++ dato-agent/src/lib/navigation.ts | 24 ++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/dato-agent/src/lib/navigation.test.ts b/dato-agent/src/lib/navigation.test.ts index dbd4eb3d..ea7bff8f 100644 --- a/dato-agent/src/lib/navigation.test.ts +++ b/dato-agent/src/lib/navigation.test.ts @@ -37,6 +37,31 @@ describe('createInspectorNavigator', () => { expect(ctx.editUpload).toHaveBeenCalledWith('upload-a'); }); + it('fills the record list when it opens a record, so Back never lands on an empty list', async () => { + const ctx = { + editUpload: vi.fn(), + setInspectorItemListData: vi.fn().mockResolvedValue(undefined), + setInspectorMode: vi.fn().mockResolvedValue(undefined), + }; + const navigator = createInspectorNavigator(ctx as never); + + await navigator.openRecord({ itemId: 'a' }); + expect(ctx.setInspectorItemListData).toHaveBeenLastCalledWith({ + title: 'Records opened from the chat', + itemIds: ['a'], + }); + + await navigator.openRecord({ itemId: 'b' }); + expect(ctx.setInspectorItemListData).toHaveBeenLastCalledWith({ + title: 'Records opened from the chat', + itemIds: ['b', 'a'], + }); + // The list is filled before the editor opens. + expect( + ctx.setInspectorItemListData.mock.invocationCallOrder.at(-1), + ).toBeLessThan(ctx.setInspectorMode.mock.invocationCallOrder.at(-1) ?? 0); + }); + it('reports when the native asset editor deletes an upload', async () => { const ctx = { editUpload: vi.fn().mockResolvedValue({ deleted: true }), diff --git a/dato-agent/src/lib/navigation.ts b/dato-agent/src/lib/navigation.ts index 3d463a56..27ff7b78 100644 --- a/dato-agent/src/lib/navigation.ts +++ b/dato-agent/src/lib/navigation.ts @@ -33,12 +33,30 @@ export type AgentNavigator = { openAsset(target: AssetTarget): Promise; }; +/** How many records the inspector's fallback list keeps. */ +const MAX_LISTED_RECORDS = 50; + export function createInspectorNavigator( ctx: RenderInspectorCtx, ): AgentNavigator { + // The record editor's back arrow switches the right pane to the record list, + // and the CMS shows an empty list as an endless spinner. So the list always + // holds the records the chat showed or opened, and Back lands somewhere real. + let list = { title: 'Records opened from the chat', itemIds: [] as string[] }; + return { supportsRecordList: true, async openRecord(target) { + if (!list.itemIds.includes(target.itemId)) { + list = { + ...list, + itemIds: [target.itemId, ...list.itemIds].slice( + 0, + MAX_LISTED_RECORDS, + ), + }; + } + await ctx.setInspectorItemListData(list); await ctx.setInspectorMode({ type: 'itemEditor', itemId: target.itemId, @@ -50,10 +68,8 @@ export function createInspectorNavigator( ...new Set(target.records.map((record) => record.itemId)), ]; - await ctx.setInspectorItemListData({ - title: target.title || 'Records', - itemIds, - }); + list = { title: target.title || 'Records', itemIds }; + await ctx.setInspectorItemListData(list); await ctx.setInspectorMode({ type: 'itemList' }); }, async openAsset(target) {