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, diff --git a/dato-agent/src/components/AgentIcons.tsx b/dato-agent/src/components/AgentIcons.tsx index 95436c3d..ce39ab30 100644 --- a/dato-agent/src/components/AgentIcons.tsx +++ b/dato-agent/src/components/AgentIcons.tsx @@ -137,6 +137,27 @@ 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 ( + + + + + ); +} + export function RetryIcon({ className }: IconProps) { return ( diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css index f98dd120..329c6ce0 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; @@ -327,29 +390,122 @@ font-weight: var(--font-weight-bold); } -.configurationNotice { +.setupSteps { + display: grid; + gap: var(--spacing-m); + margin: var(--spacing-m) 0 0; + padding: 0; + list-style: none; +} + +.setupStep { + display: grid; + gap: var(--spacing-s); +} + +.setupStepHeader { display: flex; - min-height: 34px; + min-height: 24px; align-items: center; gap: 8px; - margin-top: var(--spacing-m); - padding: 6px 8px; +} + +.setupStepNumber { + display: grid; + width: 20px; + height: 20px; + flex: none; + place-items: center; border: 1px solid var(--color--border); - background: var(--color--surface-muted); - font-size: var(--font-size-s); + border-radius: var(--radius-full, 999px); + color: var(--color--ink-subtle); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); } -.configurationNotice + .connectionForm { - margin-top: var(--spacing-s); +.setupStepTitle { + flex: 1; + min-width: 0; } -.configurationNotice svg { - width: 15px; - height: 15px; - flex: none; +.setupStepDone, +.setupStepToDo { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + border-radius: var(--radius-full, 999px); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); +} + +.setupStepDone { + background: var(--color--success-soft--surface); + color: var(--color--success-soft--ink); +} + +.setupStepToDo { + background: var(--color--warning-soft--surface); color: var(--color--warning-soft--ink); } +.setupStepDone svg, +.setupStepToDo svg { + width: 12px; + height: 12px; +} + +.setupStepBody { + display: grid; + justify-items: start; + gap: var(--spacing-m); + margin-left: 28px; + color: var(--color--ink-subtle); + font-size: var(--font-size-xs); +} + +.setupStepBody p { + 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; +} + +.chatHistory .recentChats { + margin-top: 0; + padding-top: 0; + border-top: 0; +} + .recentChats { margin-top: 18px; padding-top: 14px; @@ -485,7 +641,7 @@ .transcriptInner { box-sizing: border-box; - width: 100%; + width: min(100%, 760px); min-height: 100%; margin: 0 auto; padding: 14px 14px 18px; @@ -1170,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/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx index fd0c5fc5..aa76797a 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', @@ -1803,7 +1822,8 @@ describe('AgentSurface', () => { />, ); - expect(screen.getByText('OpenAI setup required')).toBeVisible(); + expect(screen.getByText('Choose AI provider & model')).toBeVisible(); + expect(screen.getAllByText('To do')).toHaveLength(2); expect(screen.queryByLabelText('API key')).not.toBeInTheDocument(); expect( screen.queryByRole('switch', { @@ -1817,7 +1837,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()} />, ); 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.getByText('Marketing website')).toBeVisible(); - expect(screen.getByRole('img', { name: 'Connected' })).toBeVisible(); - 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 disconnectButton = screen.getByRole('button', { - name: 'Disconnect DatoCMS', + + // 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 AI settings.', + ), + ).toBeVisible(); + await user.click(screen.getByText('Reconnect to change access…')); + const dialog = screen.getByRole('dialog', { + name: 'Reconnect to change access?', }); - expect(disconnectButton).toHaveTextContent(''); - expect(within(disconnectButton).queryByRole('img')).not.toBeInTheDocument(); - await user.click(disconnectButton); - expect(onDisconnectDatoCms).toHaveBeenCalledOnce(); + 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(); }); it('shows only six provided recent chats on the connection page', async () => { const user = userEvent.setup(); const onSelectConversation = vi.fn(); const onStartNewChat = vi.fn(); - const setupConnection: AgentConnectionViewModel = { - status: 'setup', - openAiConfigStatus: 'configured', - datoCmsStatus: 'disconnected', - }; - render( { />, ); + await user.click(screen.getByRole('button', { name: 'Open chats' })); expect(screen.getByRole('heading', { name: 'Chats' })).toBeVisible(); const currentChat = screen .getByText('Describe this project') @@ -1897,18 +1931,68 @@ describe('AgentSurface', () => { screen.queryByText('This chat is outside the six-item limit'), ).not.toBeInTheDocument(); - await user.click( - screen.getByText('Update the homepage').closest('button') as HTMLElement, - ); - expect(onSelectConversation).toHaveBeenCalledWith(recentConversations[1]); - expect(screen.getByRole('button', { name: 'Connect' })).toBeVisible(); - const newChatButton = screen.getByRole('button', { name: 'New chat' }); expect(newChatButton).toHaveTextContent(''); expect(within(newChatButton).queryByRole('img')).not.toBeInTheDocument(); - await user.click(newChatButton); - expect(onStartNewChat).toHaveBeenCalledOnce(); - expect(screen.getByRole('button', { name: 'Connect' })).toBeVisible(); + }); + + it('lists setup as numbered steps and links admins to the plugin settings', async () => { + const user = userEvent.setup(); + const onOpenPluginSettings = vi.fn(); + + render( + , + ); + + const [providerStep, datoCmsStep] = screen.getAllByRole('listitem'); + expect(providerStep).toHaveTextContent('Choose AI provider & model'); + expect(providerStep).toHaveTextContent('To do'); + 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(); + + await user.click( + screen.getByRole('button', { name: 'Open plugin settings' }), + ); + expect(onOpenPluginSettings).toHaveBeenCalledOnce(); + expect( + screen.getByText('Finish setup first to start chatting.'), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'New chat' }), + ).not.toBeInTheDocument(); + }); + + it('tells editors who cannot edit the plugin settings to ask for help', () => { + render( + , + ); + + expect(screen.getByText(/Ask them for help/)).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Open plugin settings' }), + ).not.toBeInTheDocument(); }); it('closes connected settings after selecting or starting a chat', async () => { @@ -1987,10 +2071,10 @@ 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: 'Chats' })).toBeVisible(); }); - it('disables chat history actions while the connection is in progress', () => { + it('shows no chat controls until both setup steps are done', () => { const connecting: AgentConnectionViewModel = { status: 'connecting', openAiConfigStatus: 'configured', @@ -2008,10 +2092,18 @@ describe('AgentSurface', () => { />, ); - expect(screen.getByRole('button', { name: 'New chat' })).toBeDisabled(); expect( - screen.getByText('Describe this project').closest('button'), - ).toBeDisabled(); + screen.getByText('Finish setup first to start chatting.'), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'New chat' }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Describe this project')).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 () => { @@ -2042,8 +2134,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); @@ -2077,8 +2169,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); }); @@ -2324,7 +2416,31 @@ describe('AgentSurface', () => { expect(onCheckDatoCmsAccess).toHaveBeenCalledOnce(); }); - it('shows OAuth access separately from plugin Read Only and explains how to change it', async () => { + 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 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.', + 'Plugin Read Only is on, so the agent can change nothing.', ), ).toBeVisible(); - expect( - screen.getByText(/Schema and management changes are unavailable/), - ).toBeVisible(); - 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/), - ).toBeVisible(); + expect(screen.getByText('Reconnect to change access…')).toBeVisible(); + 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 0ab660d9..4dba7f87 100644 --- a/dato-agent/src/components/AgentSurface.tsx +++ b/dato-agent/src/components/AgentSurface.tsx @@ -1,14 +1,21 @@ import { BackIcon, Button, - Form, + Dropdown, + DropdownMenu, + DropdownOption, + DropdownSeparator, + DropdownText, + FieldError, Spinner, + TextareaInput, Tooltip, TooltipContent, TooltipDelayGroup, TooltipTrigger, } from 'datocms-react-ui'; import { + type ReactNode, type RefObject, useCallback, useEffect, @@ -16,14 +23,8 @@ import { useRef, useState, } from 'react'; -import datoMarkUrl from '../assets/dato-mark.svg'; 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, @@ -36,13 +37,14 @@ import { MentionDisplay } from '../recordComments/entrypoints/components/shared/ import commentStyles from '../recordComments/entrypoints/styles/comment.module.css'; import { BoltIcon, + BoltOffIcon, CheckIcon, CircleCheckIcon, ConnectionIcon, CopyIcon, - DisconnectIcon, EditIcon, HistoryIcon, + MoreIcon, PlusIcon, RetryIcon, WarningIcon, @@ -82,6 +84,8 @@ export type AgentConnectionViewModel = { oauthAccessLevel?: McpAccessLevel; pluginReadOnly?: boolean; accessChecking?: boolean; + /** True until the first access check for the current sign-in returns. */ + accessCheckPending?: boolean; }; export type AgentConversationSummaryViewModel = { @@ -288,13 +292,19 @@ export type AgentSurfaceProps = { mentionHost?: AgentMentionHost; onConnectDatoCms?: () => void; onDisconnectDatoCms?: () => void; + /** Signs in again so the editor can pick another access level. */ + onReconnectDatoCms?: () => void; onCheckDatoCmsAccess?: () => void; - onRestartDatoCmsConnection?: () => void; recentConversations?: readonly AgentConversationSummaryViewModel[]; onSelectConversation?: ( 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, @@ -345,11 +355,12 @@ type CredentialPanelProps = Pick< | 'connection' | 'onConnectDatoCms' | 'onDisconnectDatoCms' + | 'onReconnectDatoCms' | 'onCheckDatoCmsAccess' - | 'onRestartDatoCmsConnection' | 'recentConversations' | 'onSelectConversation' | 'onStartNewChat' + | 'onOpenPluginSettings' >; type AccessNoticeProps = Pick< @@ -357,23 +368,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' && (

@@ -384,24 +395,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 - -

- )} -
); } @@ -410,7 +403,12 @@ function UnverifiedAccessNotice({ connection, onCheckDatoCmsAccess, }: AccessNoticeProps) { - if (connection.oauthAccessLevel !== 'unknown') return null; + // Before the first check returns, "unknown" only means "not checked yet". + if ( + connection.oauthAccessLevel !== 'unknown' || + connection.accessCheckPending + ) + return null; return (

@@ -433,17 +431,7 @@ function UnverifiedAccessNotice({ function DatoCmsConnectionField({ connection, onConnectDatoCms, - onDisconnectDatoCms, - onCheckDatoCmsAccess, - onRestartDatoCmsConnection, -}: Pick< - CredentialPanelProps, - | 'connection' - | 'onConnectDatoCms' - | 'onDisconnectDatoCms' - | 'onCheckDatoCmsAccess' - | 'onRestartDatoCmsConnection' ->) { +}: Pick) { const datoCmsConnected = connection.datoCmsStatus === 'connected'; const datoCmsConnecting = connection.datoCmsStatus === 'connecting'; const accountLabel = connection.datoCmsAccountLabel?.trim(); @@ -453,102 +441,77 @@ 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}.

- )} -
+ + + ) : ( + <> +

+ 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. +

+
+ + + )} - {connection.datoCmsError && (

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

)} - +
); } 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; @@ -560,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; } @@ -569,7 +533,13 @@ function RecentChats({
-

Chats

+

+ Chats +

{onStartNewChat && ( @@ -631,12 +601,12 @@ function RecentChats({ function CredentialPanel({ connection, onConnectDatoCms, - onDisconnectDatoCms, + onReconnectDatoCms, onCheckDatoCmsAccess, - onRestartDatoCmsConnection, recentConversations = [], onSelectConversation, onStartNewChat, + onOpenPluginSettings, isSettings, headingRef, conversationActionsDisabled, @@ -647,9 +617,27 @@ function CredentialPanel({ }) { const providerConfigStatus = connection.providerConfigStatus ?? connection.openAiConfigStatus; - const configuredProviderLabel = - connection.providerLabel ?? - (connection.openAiConfigStatus ? 'OpenAI' : 'AI provider'); + const isProviderReady = providerConfigStatus !== 'missing'; + 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 (
- Connection + Agent setup - {providerConfigStatus === 'missing' && ( -
- - {configuredProviderLabel} setup required -
- )} - - - +
    + + {!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(); + const errorId = useId(); + return ( +
    +
    +
    + +

    Chats

    +
    +
    +
    + +
    + Finish setup first to start chatting. +
    +
    +
    + ); +} + function EmptyState() { return (
    @@ -2124,7 +2196,197 @@ 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 can change nothing.'; + switch (connection.oauthAccessLevel) { + case 'content_view_only': + return 'The agent can read content, but not change it.'; + case 'content_only': + return 'The agent can edit content, but not the schema.'; + case 'unrestricted': + return 'The agent 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 ( + <> + ( + + )} + > + + + AI settings + + {!onOpenPluginSettings && ( + + Only users who can edit the schema can change the AI settings. + + )} + + + + Current DatoCMS access level:{' '} + {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, + isEnabled, +}: { + isChanging: boolean; + isEnabled: boolean; +}) { + const icon = isChanging ? ( + + ) : isEnabled ? ( + + ) : ( + + ); + return ( + <> + {icon} + {isEnabled ? 'Auto: On' : 'Auto-approve is off'} + {isEnabled && ( + + )} + + ); +} + function UtilityControls({ + connection, + onOpenPluginSettings, + onReconnectDatoCms, connected, chatsOpen, autoApproveEnabled, @@ -2136,6 +2398,9 @@ function UtilityControls({ onCloseChats, onOpenChats, }: { + connection: AgentConnectionViewModel; + onOpenPluginSettings?: () => void; + onReconnectDatoCms?: () => void; connected: boolean; chatsOpen: boolean; autoApproveEnabled: boolean; @@ -2183,31 +2448,36 @@ function UtilityControls({ )} - {!chatsOpen && ( - - - - - {autoApproveLabel} - - )} +
    + {!chatsOpen && ( + + + + + {autoApproveLabel} + + )} + +
    {autoApproveError && !chatsOpen && ( {autoApproveError} @@ -2227,12 +2497,12 @@ export function AgentSurface({ onSubmit, mentionHost, onConnectDatoCms, - onDisconnectDatoCms, + onReconnectDatoCms, onCheckDatoCmsAccess, - onRestartDatoCmsConnection, recentConversations = [], onSelectConversation, onStartNewChat, + onOpenPluginSettings, onStop, onOpenRecord, onOpenField, @@ -2394,6 +2664,9 @@ export function AgentSurface({ return (
    { @@ -2440,6 +2712,7 @@ export function AgentSurface({ } : undefined } + onOpenPluginSettings={onOpenPluginSettings} recentConversations={recentConversations} />
    diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx index fc5aa03e..e5f0471c 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,68 @@ 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('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( + '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 9d648c8b..d4e29f16 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`. @@ -1834,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( @@ -1990,6 +2011,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 @@ -2923,7 +2946,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]); @@ -5550,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) { @@ -5566,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 || @@ -6089,6 +6141,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, }} @@ -6126,13 +6181,13 @@ export default function AgentFrame(props: AgentFrameProps) { onCopyFailureDiagnostics={copyFailureDiagnostics} onConnectDatoCms={() => void connectDatoCms()} onDisconnectDatoCms={() => void disconnectDatoCms()} + onReconnectDatoCms={() => void reconnectDatoCms()} onCheckDatoCmsAccess={() => { setAccessChecking(true); void refreshMcpAccess() .catch(() => undefined) .finally(() => setAccessChecking(false)); }} - onRestartDatoCmsConnection={() => void connectDatoCms(true)} recentConversations={storedConversations.map((stored) => ({ id: stored.id, title: stored.title, @@ -6140,6 +6195,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.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 5bef8970..cdccdd76 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'; @@ -9,10 +9,18 @@ import { createModelSchemaResolver, } from '../lib/hostContext'; import { resolveFieldsWithinDeadline } from '../lib/hostContextDeadline'; +import { + AGENT_PANE_WIDTH, + useRememberedAgentPaneWidth, +} from '../lib/inspectorLayout'; 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 styles from './AgentInspector.module.css'; import AgentUnavailableFrame from './AgentUnavailableFrame'; type Props = { @@ -264,35 +272,54 @@ function AuthorizedAgentInspector({ ctx }: Props) { 'project', ].join(':'); const mentionHost = createAgentMentionHost(ctx); + useRememberedAgentPaneWidth(); + const isTooNarrow = useMediaQuery( + `(max-width: ${AGENT_PANE_WIDTH - 1}px)`, + ).matches; return ( - ({ - 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, + }) + } + /> +
    ); } 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/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', 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. +

    ); diff --git a/dato-agent/src/lib/inspectorLayout.test.ts b/dato-agent/src/lib/inspectorLayout.test.ts index c7660dc8..f05156b4 100644 --- a/dato-agent/src/lib/inspectorLayout.test.ts +++ b/dato-agent/src/lib/inspectorLayout.test.ts @@ -1,13 +1,18 @@ -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([ - [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 +23,48 @@ 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); + }); +}); + +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 for every project in this browser', () => { + resizeTo({ inner: 300, outer: 1440 }); + const { unmount } = renderHook(() => useRememberedAgentPaneWidth()); + + resizeTo({ inner: 520, outer: 1440 }); + expect(readAgentPaneWidth()).toBe(520); + + // Some browsers report the iframe's own size as outerWidth during a drag. + resizeTo({ inner: 610, outer: 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()); + resizeTo({ inner: 180, outer: 1440 }); + expect(readAgentPaneWidth()).toBeUndefined(); + unmount(); }); }); diff --git a/dato-agent/src/lib/inspectorLayout.ts b/dato-agent/src/lib/inspectorLayout.ts index b2335eda..7774f87f 100644 --- a/dato-agent/src/lib/inspectorLayout.ts +++ b/dato-agent/src/lib/inspectorLayout.ts @@ -1,9 +1,17 @@ +import { useEffect } from 'react'; + 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; +/** 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' && @@ -14,6 +22,52 @@ export function inspectorRecordPaneWidth( return Math.max( MINIMUM_RECORD_PANE_WIDTH, - Math.round(browserWidth - TARGET_AGENT_PANE_WIDTH), + Math.round(browserWidth - agentWidth), ); } + +/** + * 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(): number | undefined { + try { + const width = Number.parseInt( + localStorage.getItem(AGENT_WIDTH_KEY) ?? '', + 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(): void { + useEffect(() => { + const remember = () => { + if (window.innerWidth < AGENT_PANE_WIDTH) return; + try { + localStorage.setItem( + AGENT_WIDTH_KEY, + String(Math.round(window.innerWidth)), + ); + } catch { + // Dragging still works when iframe storage is blocked. + } + }; + window.addEventListener('resize', remember); + return () => window.removeEventListener('resize', remember); + }, []); +} diff --git a/dato-agent/src/lib/mcpAccess.ts b/dato-agent/src/lib/mcpAccess.ts index 23a5391e..b3b1b0ca 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 "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 { diff --git a/dato-agent/src/lib/navigation.test.ts b/dato-agent/src/lib/navigation.test.ts index 13cd7b97..ea7bff8f 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 () => { @@ -33,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 }), @@ -443,3 +472,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..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) { @@ -133,3 +149,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`; +} 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], }; } diff --git a/dato-agent/src/main.tsx b/dato-agent/src/main.tsx index 08592350..453ebd4d 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(), + ), initialInspectorPanel: { panelId: 'dato-agent-empty', }, diff --git a/dato-agent/src/test/datocms-react-ui-canvas.d.ts b/dato-agent/src/test/datocms-react-ui-canvas.d.ts new file mode 100644 index 00000000..94486fe6 --- /dev/null +++ b/dato-agent/src/test/datocms-react-ui-canvas.d.ts @@ -0,0 +1,5 @@ +// The tests provide the kit's plugin ctx directly. The package ships the types of +// this deep path separately, under dist/types. +declare module 'datocms-react-ui/dist/cjs/Canvas' { + export { CtxContext } from 'datocms-react-ui/dist/types/Canvas'; +} diff --git a/dato-agent/src/test/setup.ts b/dato-agent/src/test/setup.ts index 413e6937..8ec26b10 100644 --- a/dato-agent/src/test/setup.ts +++ b/dato-agent/src/test/setup.ts @@ -16,6 +16,19 @@ if (jsdomWindow) { } } +// The kit's Dropdown menu watches its own visibility; jsdom has no IntersectionObserver. +globalThis.IntersectionObserver = class { + readonly root = null; + readonly rootMargin = ''; + readonly thresholds = []; + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} as unknown as typeof IntersectionObserver; + globalThis.ResizeObserver = class { observe() {} unobserve() {}