diff --git a/dato-agent/src/components/AgentIcons.tsx b/dato-agent/src/components/AgentIcons.tsx
index 95436c3d..4c0d7809 100644
--- a/dato-agent/src/components/AgentIcons.tsx
+++ b/dato-agent/src/components/AgentIcons.tsx
@@ -154,6 +154,16 @@ export function CopyIcon({ className }: IconProps) {
);
}
+export function ExternalLinkIcon({ className }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
export function EditIcon({ className }: IconProps) {
return (
diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css
index 860a527a..6d99ea67 100644
--- a/dato-agent/src/components/AgentSurface.module.css
+++ b/dato-agent/src/components/AgentSurface.module.css
@@ -1184,6 +1184,43 @@
flex: 1 1 auto;
}
+.mentionReceiptItems .recordExternalLink {
+ display: inline-grid;
+ width: 22px;
+ height: 22px;
+ flex: none;
+ place-items: center;
+ padding: 0;
+ overflow: visible;
+ border: 0;
+ border-radius: 4px;
+ color: var(--color--ink-subtle);
+ background: transparent;
+ cursor: pointer;
+}
+
+.mentionReceiptItems .recordExternalLink svg {
+ width: 12px;
+ height: 12px;
+}
+
+.mentionReceiptItems .recordExternalLink:hover,
+.mentionReceiptItems .recordExternalLink:focus-visible {
+ color: var(--color--ink);
+ background: var(--color--surface-hover);
+}
+
+.mentionReceiptItems .recordExternalLink:focus-visible {
+ outline: 2px solid var(--color--focus--outline);
+ outline-offset: 1px;
+}
+
+.mentionReceiptItems .recordExternalLink:disabled {
+ color: var(--color--disabled--ink);
+ cursor: default;
+ background: transparent;
+}
+
.mentionReceiptState {
flex: none;
color: var(--color--ink-subtle);
diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx
index 1f3c3fc2..246b7099 100644
--- a/dato-agent/src/components/AgentSurface.test.tsx
+++ b/dato-agent/src/components/AgentSurface.test.tsx
@@ -731,6 +731,38 @@ describe('AgentSurface', () => {
);
});
+ it('opens a record result in the full editor without opening it in the pane', async () => {
+ const user = userEvent.setup();
+ const onOpenRecord = vi.fn();
+ const onOpenRecordInFullEditor = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', { name: 'Open Two in the full editor' }),
+ );
+ expect(onOpenRecordInFullEditor).toHaveBeenCalledWith(
+ { itemId: 'two', itemTypeId: 'post', title: 'Two' },
+ 'records',
+ );
+ expect(onOpenRecord).not.toHaveBeenCalled();
+ });
+
it('opens record, field, asset, model, and user mentions directly from user messages', async () => {
const user = userEvent.setup();
const onOpenRecord = vi.fn();
diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx
index 416398b4..de3146ba 100644
--- a/dato-agent/src/components/AgentSurface.tsx
+++ b/dato-agent/src/components/AgentSurface.tsx
@@ -41,6 +41,7 @@ import {
CopyIcon,
DisconnectIcon,
EditIcon,
+ ExternalLinkIcon,
HistoryIcon,
PlusIcon,
RetryIcon,
@@ -299,6 +300,10 @@ export type AgentSurfaceProps = {
record: AgentRecordResultViewModel,
entryId?: string,
) => void | Promise;
+ onOpenRecordInFullEditor?: (
+ record: AgentRecordResultViewModel,
+ entryId?: string,
+ ) => void | Promise;
onOpenField?: (
field: AgentFieldResultViewModel,
entryId?: string,
@@ -1144,12 +1149,14 @@ function RecordsEntry({
entry,
mentionHost,
onOpenRecord,
+ onOpenRecordInFullEditor,
}: {
disabled?: boolean;
currentRecordId?: string;
entry: AgentRecordsEntry;
mentionHost: AgentMentionHost;
onOpenRecord?: AgentSurfaceProps['onOpenRecord'];
+ onOpenRecordInFullEditor?: AgentSurfaceProps['onOpenRecordInFullEditor'];
}) {
const modelsById = new Map(
mentionHost.projectModels.map((model) => [model.id, model]),
@@ -1187,20 +1194,41 @@ function RecordsEntry({
: {}),
});
return (
- {
- if (!disabled && onOpenRecord) {
- void Promise.resolve(onOpenRecord(record, entry.id)).catch(
- () => undefined,
- );
- }
- }}
- />
+ >
+ {
+ if (!disabled && onOpenRecord) {
+ void Promise.resolve(onOpenRecord(record, entry.id)).catch(
+ () => undefined,
+ );
+ }
+ }}
+ />
+ {onOpenRecordInFullEditor && (
+
+ )}
+
);
})}
@@ -1799,6 +1827,7 @@ function AgentTurnEntry({
onOpenAsset,
onOpenField,
onOpenRecord,
+ onOpenRecordInFullEditor,
onRejectUnsafeAction,
onRepairUnsafeAction,
onRetryFailedTurn,
@@ -1814,6 +1843,7 @@ function AgentTurnEntry({
onOpenAsset?: AgentSurfaceProps['onOpenAsset'];
onOpenField?: AgentSurfaceProps['onOpenField'];
onOpenRecord?: AgentSurfaceProps['onOpenRecord'];
+ onOpenRecordInFullEditor?: AgentSurfaceProps['onOpenRecordInFullEditor'];
onRejectUnsafeAction?: AgentSurfaceProps['onRejectUnsafeAction'];
onRepairUnsafeAction?: AgentSurfaceProps['onRepairUnsafeAction'];
onRetryFailedTurn?: AgentSurfaceProps['onRetryFailedTurn'];
@@ -1859,6 +1889,7 @@ function AgentTurnEntry({
key={entry.id}
mentionHost={mentionHost}
onOpenRecord={onOpenRecord}
+ onOpenRecordInFullEditor={onOpenRecordInFullEditor}
/>
);
case 'fields':
@@ -1917,6 +1948,7 @@ function Transcript({
entries,
mentionHost,
onOpenRecord,
+ onOpenRecordInFullEditor,
onOpenField,
onOpenAsset,
onReviewUnsafeAction,
@@ -1933,6 +1965,7 @@ function Transcript({
| 'autoApproveEnabled'
| 'currentRecordId'
| 'onOpenRecord'
+ | 'onOpenRecordInFullEditor'
| 'onOpenField'
| 'onOpenAsset'
| 'onReviewUnsafeAction'
@@ -2026,6 +2059,7 @@ function Transcript({
onOpenAsset={onOpenAsset}
onOpenField={onOpenField}
onOpenRecord={onOpenRecord}
+ onOpenRecordInFullEditor={onOpenRecordInFullEditor}
onRejectUnsafeAction={onRejectUnsafeAction}
onRepairUnsafeAction={onRepairUnsafeAction}
onRetryFailedTurn={onRetryFailedTurn}
@@ -2151,6 +2185,7 @@ export function AgentSurface({
onStartNewChat,
onStop,
onOpenRecord,
+ onOpenRecordInFullEditor,
onOpenField,
onOpenAsset,
hostActionPending = false,
@@ -2372,6 +2407,7 @@ export function AgentSurface({
onOpenAsset={onOpenAsset}
onOpenField={onOpenField}
onOpenRecord={onOpenRecord}
+ onOpenRecordInFullEditor={onOpenRecordInFullEditor}
hostActionPending={hostActionPending}
onRejectUnsafeAction={onRejectUnsafeAction}
onRepairUnsafeAction={onRepairUnsafeAction}
diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx
index 03f8cf89..92ca8dd5 100644
--- a/dato-agent/src/entrypoints/AgentFrame.test.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx
@@ -130,6 +130,10 @@ function props(overrides: Partial = {}): AgentFrameProps {
scope: { type: 'project' },
navigator: {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -761,6 +765,10 @@ describe('AgentFrame', () => {
surface: 'project',
navigator: {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord,
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -3662,6 +3670,10 @@ describe('AgentFrame', () => {
const onConfirmEnableAutoApprove = vi.fn().mockResolvedValue(true);
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -3720,6 +3732,10 @@ describe('AgentFrame', () => {
const onConfirmEnableAutoApprove = vi.fn().mockResolvedValue(true);
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn(() => recordModal),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -3783,6 +3799,10 @@ describe('AgentFrame', () => {
const onReviewApprovalDetails = vi.fn().mockResolvedValue(null);
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -4600,6 +4620,10 @@ describe('AgentFrame', () => {
it('keeps a completed answer when native navigation rejects', async () => {
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi
.fn()
.mockRejectedValue(new Error('Navigation unavailable')),
@@ -4703,6 +4727,10 @@ describe('AgentFrame', () => {
} satisfies AgentMentionHost;
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -5293,6 +5321,10 @@ describe('AgentFrame', () => {
async (provider) => {
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -5365,6 +5397,10 @@ describe('AgentFrame', () => {
it('turns show_records into clickable results when the surface has no native list', async () => {
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -5422,6 +5458,10 @@ describe('AgentFrame', () => {
it('presents clickable record results without changing the CMS view', async () => {
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -5534,6 +5574,10 @@ describe('AgentFrame', () => {
async (provider) => {
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -5597,6 +5641,10 @@ describe('AgentFrame', () => {
it('shows record receipt navigation errors without rejecting the click', async () => {
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockRejectedValue(new Error('Access denied.')),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -5659,6 +5707,10 @@ describe('AgentFrame', () => {
it('presents, opens, and restores verified field and asset receipts', async () => {
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: true }),
@@ -5842,6 +5894,10 @@ describe('AgentFrame', () => {
});
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn(() => assetModal),
@@ -5916,6 +5972,10 @@ describe('AgentFrame', () => {
});
const navigator = {
supportsRecordList: false,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn(() => opening),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -7382,6 +7442,10 @@ describe('AgentFrame', () => {
});
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn(() => recordDialog),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -7474,6 +7538,10 @@ describe('AgentFrame', () => {
});
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn(() => recordDialog),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
@@ -7557,6 +7625,10 @@ describe('AgentFrame', () => {
});
const navigator = {
supportsRecordList: true,
+ openRecordInFullEditor: vi.fn().mockResolvedValue({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor',
+ }),
openRecord: vi.fn().mockResolvedValue(undefined),
showRecords: vi.fn().mockResolvedValue(undefined),
openAsset: vi.fn().mockResolvedValue({ deleted: false }),
diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx
index d739546b..0d609ef4 100644
--- a/dato-agent/src/entrypoints/AgentFrame.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.tsx
@@ -3595,6 +3595,18 @@ export default function AgentFrame(props: AgentFrameProps) {
await appendRecordResults('Record found', [target]);
return queuePendingNavigation({ type: 'openRecord', target });
},
+ openRecordInFullEditor: async ({ itemId, itemTypeId }) => {
+ const target = { itemId, itemTypeId };
+ const result = await props.navigator.openRecordInFullEditor(target);
+ await appendRecordResults('Record found', [target]);
+ return {
+ opened: result.opened,
+ url: result.url,
+ message: result.opened
+ ? 'The full record editor was opened in a new browser tab, including the standard sidebar.'
+ : `The browser blocked the new tab. Share this URL so the editor can open it: ${result.url}`,
+ };
+ },
showRecords: async ({ title, records }) => {
await appendRecordResults(title, records);
if (!props.navigator.supportsRecordList) {
@@ -6114,6 +6126,13 @@ export default function AgentFrame(props: AgentFrameProps) {
onOpenAsset={openAsset}
onOpenField={props.openCurrentField ? openField : undefined}
onOpenRecord={openRecord}
+ onOpenRecordInFullEditor={async (record) => {
+ await props.navigator.openRecordInFullEditor({
+ itemId: record.itemId,
+ itemTypeId: record.itemTypeId,
+ label: record.title,
+ });
+ }}
currentRecordId={
surface === 'project' ? currentInspectorRecordId : undefined
}
diff --git a/dato-agent/src/lib/activityLabels.ts b/dato-agent/src/lib/activityLabels.ts
index b20c4bb6..4d06dc10 100644
--- a/dato-agent/src/lib/activityLabels.ts
+++ b/dato-agent/src/lib/activityLabels.ts
@@ -725,6 +725,8 @@ function localInProgressLabel(
}
case 'open_record':
return 'Opening a record';
+ case 'open_record_in_full_editor':
+ return 'Opening the full editor';
case 'show_records': {
return prefixedCountLabel(
argumentsRecord,
@@ -813,6 +815,8 @@ function localCompletedLabel(
}
case 'open_record':
return 'Record ready';
+ case 'open_record_in_full_editor':
+ return 'Full editor ready';
case 'show_records': {
return suffixedCountLabel(
argumentsRecord,
@@ -894,6 +898,8 @@ function localFailedLabel(toolName: string): string {
switch (toolName) {
case 'get_model_schema':
return 'Could not read model fields';
+ case 'open_record_in_full_editor':
+ return 'Could not open the full editor';
case 'present_records':
return 'Could not add record links';
case 'present_fields':
diff --git a/dato-agent/src/lib/agentRuntime.test.ts b/dato-agent/src/lib/agentRuntime.test.ts
index 3d221ff5..82ff0587 100644
--- a/dato-agent/src/lib/agentRuntime.test.ts
+++ b/dato-agent/src/lib/agentRuntime.test.ts
@@ -1087,6 +1087,48 @@ describe('AgentRuntime', () => {
expect(toolNames).not.toContain('present_fields');
expect(toolNames).not.toContain('read_current_record_live_form_state');
expect(toolNames).toContain('present_assets');
+ expect(toolNames).not.toContain('open_record_in_full_editor');
+ });
+
+ it('opens the full record editor and reports the URL when the tab is blocked', async () => {
+ const openCall = {
+ type: 'function_call',
+ id: 'function-full',
+ call_id: 'call-full',
+ name: 'open_record_in_full_editor',
+ arguments: JSON.stringify({
+ item_id: 'item-1',
+ item_type_id: 'article',
+ }),
+ status: 'completed',
+ } satisfies ResponseFunctionToolCall;
+ const client = new QueueResponsesClient([
+ eventsFor(response('resp-full', [openCall])),
+ eventsFor(response('resp-full-done'), ['Opened.']),
+ ]);
+ const openRecordInFullEditor = vi.fn().mockResolvedValue({
+ opened: false,
+ url: 'https://example.admin.datocms.com/editor/item_types/article/items/item-1/edit',
+ });
+ const runtime = runtimeWith(client, {
+ navigation: {
+ openRecord: vi.fn(),
+ showRecords: vi.fn(),
+ presentRecords: vi.fn(),
+ presentAssets: vi.fn(),
+ openRecordInFullEditor,
+ },
+ });
+
+ await drain(runtime.streamTurn({ message: 'Open the full editor' }));
+
+ expect(openRecordInFullEditor).toHaveBeenCalledWith({
+ itemId: 'item-1',
+ itemTypeId: 'article',
+ });
+ expect(JSON.stringify(client.requests[1]?.input)).toContain(
+ 'https://example.admin.datocms.com/editor/item_types/article/items/item-1/edit',
+ );
});
it('injects host context once and keeps continuations on the stored response chain', async () => {
diff --git a/dato-agent/src/lib/agentRuntime.ts b/dato-agent/src/lib/agentRuntime.ts
index 923d6365..4453c99c 100644
--- a/dato-agent/src/lib/agentRuntime.ts
+++ b/dato-agent/src/lib/agentRuntime.ts
@@ -550,6 +550,9 @@ export interface AgentNavigationCallbacks {
presentUsers?(
input: PresentUsersInput,
): NavigationCallbackResult | Promise;
+ openRecordInFullEditor?(
+ input: OpenRecordInput,
+ ): NavigationCallbackResult | Promise;
}
export interface GetModelSchemaInput {
@@ -1177,6 +1180,29 @@ export const LOCAL_NAVIGATION_TOOLS = [
required: ['item_id', 'item_type_id', 'field_path'],
},
},
+ {
+ type: 'function',
+ name: 'open_record_in_full_editor',
+ description:
+ 'Open one verified record in the standard DatoCMS record editor in a new browser tab. That page includes the normal record sidebar, including Web Previews when that plugin is installed. Use this when the editor asks for the sidebar, a preview, or the full record page. This leaves the current chat in place and never changes content. If the result says the browser blocked the tab, share the returned URL instead of claiming it opened.',
+ strict: true,
+ parameters: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ item_id: {
+ type: 'string',
+ description: 'A DatoCMS record ID from the authorized environment.',
+ },
+ item_type_id: {
+ type: ['string', 'null'],
+ description:
+ 'The record model ID when known, or null. Include it so the full editor can open without another lookup.',
+ },
+ },
+ required: ['item_id', 'item_type_id'],
+ },
+ },
{
type: 'function',
name: 'present_fields',
@@ -1241,6 +1267,9 @@ function availableLocalTools(
if (tool.name === 'present_users') {
return Boolean(navigation.presentUsers);
}
+ if (tool.name === 'open_record_in_full_editor') {
+ return Boolean(navigation.openRecordInFullEditor);
+ }
return true;
});
return createDatoAsset && !readOnly
@@ -3183,6 +3212,20 @@ async function* executeLocalToolCalls({
...(parsed.itemTypeId ? { itemTypeId: parsed.itemTypeId } : {}),
...(parsed.fieldPath ? { fieldPath: parsed.fieldPath } : {}),
});
+ } else if (call.name === 'open_record_in_full_editor') {
+ if (!navigation.openRecordInFullEditor) {
+ throw new Error(
+ 'The full record editor is not available on the current DatoCMS surface.',
+ );
+ }
+ const parsed = parseOpenRecordInput(call.arguments);
+ activityArguments = parsed;
+ const result = await navigation.openRecordInFullEditor(parsed);
+ output = stringifyCallbackResult(result, {
+ action: 'open_record_in_full_editor',
+ itemId: parsed.itemId,
+ ...(parsed.itemTypeId ? { itemTypeId: parsed.itemTypeId } : {}),
+ });
} else if (call.name === 'show_records') {
const parsed = parseRecordListInput(call.arguments);
activityArguments = parsed;
diff --git a/dato-agent/src/lib/mentionHost.ts b/dato-agent/src/lib/mentionHost.ts
index 469ed83d..3d28c28a 100644
--- a/dato-agent/src/lib/mentionHost.ts
+++ b/dato-agent/src/lib/mentionHost.ts
@@ -24,6 +24,7 @@ import { openFileDetailsModal } from './fileDetailsModal';
import { hasSessionLocalFileBytes } from './localFiles';
import type { AssetMention, LocalFileMention, RecordMention } from './mentions';
import { fallbackAssetMention, fallbackRecordMention } from './mentions';
+import { projectAdminUrl } from './projectUrl';
type MentionCtx = RenderInspectorCtx | RenderItemFormSidebarCtx;
@@ -376,13 +377,15 @@ function modelFields(fields: readonly Field[], locales: readonly string[]) {
);
}
-function environmentPrefix(ctx: MentionCtx) {
- return ctx.isEnvironmentPrimary ? '' : `/environments/${ctx.environment}`;
-}
-
function projectUrl(ctx: MentionCtx, path: string) {
- const domain = ctx.site.attributes.internal_domain;
- return domain ? `https://${domain}${environmentPrefix(ctx)}${path}` : null;
+ return projectAdminUrl(
+ {
+ internalDomain: ctx.site.attributes.internal_domain,
+ environment: ctx.environment,
+ isEnvironmentPrimary: ctx.isEnvironmentPrimary,
+ },
+ path,
+ );
}
function createMentionClient(ctx: MentionCtx): Client | null {
diff --git a/dato-agent/src/lib/navigation.test.ts b/dato-agent/src/lib/navigation.test.ts
index 13cd7b97..d6a5f690 100644
--- a/dato-agent/src/lib/navigation.test.ts
+++ b/dato-agent/src/lib/navigation.test.ts
@@ -1,6 +1,15 @@
-import { describe, expect, it, vi } from 'vitest';
+import { buildClient } from '@datocms/cma-client-browser';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { createInspectorNavigator, createSidebarNavigator } from './navigation';
+const findItem = vi.hoisted(() => vi.fn());
+
+vi.mock('@datocms/cma-client-browser', () => ({
+ buildClient: vi.fn(() => ({
+ items: { find: findItem },
+ })),
+}));
+
describe('createInspectorNavigator', () => {
it('uses the native record list and editor', async () => {
const ctx = {
@@ -443,3 +452,117 @@ describe('createSidebarNavigator', () => {
expect(ctx.editItem).toHaveBeenNthCalledWith(2, 'another');
});
});
+
+describe('openRecordInFullEditor', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ findItem.mockReset();
+ });
+
+ function editorContext() {
+ return {
+ cmaBaseUrl: 'https://site-api.datocms.com',
+ currentUserAccessToken: 'token',
+ environment: 'staging',
+ isEnvironmentPrimary: false,
+ site: { attributes: { internal_domain: 'example.admin.datocms.com' } },
+ editUpload: vi.fn(),
+ setInspectorItemListData: vi.fn(),
+ setInspectorMode: vi.fn(),
+ };
+ }
+
+ it('opens the standard editor for a known model without a record lookup', async () => {
+ const popup = {
+ opener: window,
+ location: { replace: vi.fn() },
+ close: vi.fn(),
+ };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+ const ctx = editorContext();
+
+ await expect(
+ createInspectorNavigator(ctx as never).openRecordInFullEditor({
+ itemId: 'item-1',
+ itemTypeId: 'post',
+ }),
+ ).resolves.toEqual({
+ opened: true,
+ url: 'https://example.admin.datocms.com/environments/staging/editor/item_types/post/items/item-1/edit',
+ });
+
+ expect(window.open).toHaveBeenCalledWith('about:blank', '_blank');
+ expect(popup.opener).toBeNull();
+ expect(popup.location.replace).toHaveBeenCalledWith(
+ 'https://example.admin.datocms.com/environments/staging/editor/item_types/post/items/item-1/edit',
+ );
+ expect(buildClient).not.toHaveBeenCalled();
+ });
+
+ it('looks up the model when the record link does not include it', async () => {
+ const popup = {
+ opener: window,
+ location: { replace: vi.fn() },
+ close: vi.fn(),
+ };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+ findItem.mockResolvedValue({
+ item_type: { id: 'page', type: 'item_type' },
+ });
+ const ctx = {
+ ...editorContext(),
+ isEnvironmentPrimary: true,
+ };
+
+ await expect(
+ createSidebarNavigator(ctx as never).openRecordInFullEditor({
+ itemId: 'item-2',
+ }),
+ ).resolves.toEqual({
+ opened: true,
+ url: 'https://example.admin.datocms.com/editor/item_types/page/items/item-2/edit',
+ });
+ expect(buildClient).toHaveBeenCalledWith({
+ apiToken: 'token',
+ environment: 'staging',
+ baseUrl: 'https://site-api.datocms.com',
+ });
+ expect(findItem).toHaveBeenCalledWith('item-2');
+ });
+
+ it('returns the URL when the browser blocks the new tab', async () => {
+ vi.spyOn(window, 'open').mockReturnValue(null);
+
+ await expect(
+ createInspectorNavigator(editorContext() as never).openRecordInFullEditor(
+ {
+ itemId: 'item-1',
+ itemTypeId: 'post',
+ },
+ ),
+ ).resolves.toEqual({
+ opened: false,
+ url: 'https://example.admin.datocms.com/environments/staging/editor/item_types/post/items/item-1/edit',
+ });
+ });
+
+ it('closes the placeholder tab when the record cannot be resolved', async () => {
+ const popup = {
+ opener: null,
+ location: { replace: vi.fn() },
+ close: vi.fn(),
+ };
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
+ findItem.mockRejectedValue(new Error('Not found'));
+
+ await expect(
+ createInspectorNavigator(editorContext() as never).openRecordInFullEditor(
+ {
+ itemId: 'missing',
+ },
+ ),
+ ).rejects.toThrow('Not found');
+ expect(popup.close).toHaveBeenCalledOnce();
+ expect(popup.location.replace).not.toHaveBeenCalled();
+ });
+});
diff --git a/dato-agent/src/lib/navigation.ts b/dato-agent/src/lib/navigation.ts
index 6c7803f8..9e8fb9d4 100644
--- a/dato-agent/src/lib/navigation.ts
+++ b/dato-agent/src/lib/navigation.ts
@@ -1,7 +1,9 @@
+import { buildClient } from '@datocms/cma-client-browser';
import type {
RenderInspectorCtx,
RenderItemFormSidebarCtx,
} from 'datocms-plugin-sdk';
+import { projectAdminUrl, recordEditorPath } from './projectUrl';
export type RecordTarget = {
itemId: string;
@@ -26,13 +28,94 @@ export type OpenAssetResult = {
title?: string;
};
+export type FullEditorResult = {
+ opened: boolean;
+ url: string;
+};
+
+type FullEditorCtx = {
+ site: { attributes: { internal_domain: string | null | undefined } };
+ environment: string;
+ isEnvironmentPrimary: boolean;
+ currentUserAccessToken: string | null | undefined;
+ cmaBaseUrl: string;
+};
+
export type AgentNavigator = {
readonly supportsRecordList: boolean;
openRecord(target: RecordTarget): Promise;
showRecords(target: RecordListTarget): Promise;
openAsset(target: AssetTarget): Promise;
+ openRecordInFullEditor(target: RecordTarget): Promise;
};
+async function resolveRecordItemTypeId(
+ ctx: FullEditorCtx,
+ itemId: string,
+): Promise {
+ if (!ctx.currentUserAccessToken) {
+ throw new Error(
+ 'Sign in to DatoCMS before opening a record in the full editor.',
+ );
+ }
+
+ const item = await buildClient({
+ apiToken: ctx.currentUserAccessToken,
+ environment: ctx.environment,
+ baseUrl: ctx.cmaBaseUrl,
+ }).items.find(itemId);
+ const itemTypeId = item.item_type.id;
+ if (!itemTypeId) {
+ throw new Error('The record model could not be determined.');
+ }
+
+ return itemTypeId;
+}
+
+async function openRecordEditorTab(
+ ctx: FullEditorCtx,
+ target: RecordTarget,
+): Promise {
+ const itemId = target.itemId.trim();
+ if (!itemId) {
+ throw new Error('A record ID is required to open the full editor.');
+ }
+
+ // Open the tab during the click, before any record lookup, so the browser
+ // still treats it as a user action.
+ const popup = window.open('about:blank', '_blank');
+ if (popup) {
+ popup.opener = null;
+ }
+
+ try {
+ const itemTypeId =
+ target.itemTypeId?.trim() || (await resolveRecordItemTypeId(ctx, itemId));
+ const url = projectAdminUrl(
+ {
+ internalDomain: ctx.site.attributes.internal_domain,
+ environment: ctx.environment,
+ isEnvironmentPrimary: ctx.isEnvironmentPrimary,
+ },
+ recordEditorPath(itemTypeId, itemId),
+ );
+ if (!url) {
+ throw new Error(
+ 'This project has no admin domain, so the full editor cannot be opened.',
+ );
+ }
+ if (!popup) {
+ return { opened: false, url };
+ }
+
+ popup.location.replace(url);
+ return { opened: true, url };
+ } catch (error) {
+ popup?.close();
+ throw error;
+ }
+}
+
export function createInspectorNavigator(
ctx: RenderInspectorCtx,
): AgentNavigator {
@@ -71,6 +154,9 @@ export function createInspectorNavigator(
),
};
},
+ openRecordInFullEditor(target) {
+ return openRecordEditorTab(ctx, target);
+ },
};
}
@@ -131,5 +217,8 @@ export function createSidebarNavigator(
),
};
},
+ openRecordInFullEditor(target) {
+ return openRecordEditorTab(ctx, target);
+ },
};
}
diff --git a/dato-agent/src/lib/projectUrl.test.ts b/dato-agent/src/lib/projectUrl.test.ts
new file mode 100644
index 00000000..50cfe86a
--- /dev/null
+++ b/dato-agent/src/lib/projectUrl.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { projectAdminUrl, recordEditorPath } from './projectUrl';
+
+describe('projectAdminUrl', () => {
+ it('omits the environment prefix on the primary environment', () => {
+ expect(
+ projectAdminUrl(
+ {
+ internalDomain: 'example.admin.datocms.com',
+ environment: 'main',
+ isEnvironmentPrimary: true,
+ },
+ '/project_settings/users',
+ ),
+ ).toBe('https://example.admin.datocms.com/project_settings/users');
+ });
+
+ it('prefixes a sandbox environment and builds the record editor path', () => {
+ expect(
+ projectAdminUrl(
+ {
+ internalDomain: ' example.admin.datocms.com ',
+ environment: 'staging',
+ isEnvironmentPrimary: false,
+ },
+ recordEditorPath('model/1', 'item 2'),
+ ),
+ ).toBe(
+ 'https://example.admin.datocms.com/environments/staging/editor/item_types/model%2F1/items/item%202/edit',
+ );
+ });
+
+ it('returns null when the project has no admin domain', () => {
+ expect(
+ projectAdminUrl(
+ {
+ internalDomain: ' ',
+ environment: 'main',
+ isEnvironmentPrimary: true,
+ },
+ '/editor',
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/dato-agent/src/lib/projectUrl.ts b/dato-agent/src/lib/projectUrl.ts
new file mode 100644
index 00000000..6e48432f
--- /dev/null
+++ b/dato-agent/src/lib/projectUrl.ts
@@ -0,0 +1,31 @@
+export type ProjectAdminUrlContext = {
+ internalDomain: string | null | undefined;
+ environment: string;
+ isEnvironmentPrimary: boolean;
+};
+
+export function environmentAdminPrefix(context: {
+ environment: string;
+ isEnvironmentPrimary: boolean;
+}): string {
+ return context.isEnvironmentPrimary
+ ? ''
+ : `/environments/${context.environment}`;
+}
+
+export function projectAdminUrl(
+ context: ProjectAdminUrlContext,
+ path: string,
+): string | null {
+ const domain = context.internalDomain?.trim();
+ if (!domain) {
+ return null;
+ }
+
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
+ return `https://${domain}${environmentAdminPrefix(context)}${normalizedPath}`;
+}
+
+export function recordEditorPath(itemTypeId: string, itemId: string): string {
+ return `/editor/item_types/${encodeURIComponent(itemTypeId)}/items/${encodeURIComponent(itemId)}/edit`;
+}
diff --git a/dato-agent/src/lib/systemPrompt.test.ts b/dato-agent/src/lib/systemPrompt.test.ts
index 88c400cc..dfb7bbf4 100644
--- a/dato-agent/src/lib/systemPrompt.test.ts
+++ b/dato-agent/src/lib/systemPrompt.test.ts
@@ -176,6 +176,7 @@ describe('buildSystemPrompt', () => {
expect(prompt).toContain(
'after successfully changing one record, call open_record',
);
+ expect(prompt).toContain('open_record_in_full_editor');
expect(prompt).toContain(
'inside the standalone Agent inspector. Use show_records',
);
diff --git a/dato-agent/src/lib/systemPrompt.ts b/dato-agent/src/lib/systemPrompt.ts
index bad0a504..457cec09 100644
--- a/dato-agent/src/lib/systemPrompt.ts
+++ b/dato-agent/src/lib/systemPrompt.ts
@@ -112,7 +112,8 @@ function recordToolGuidance(
: 'show_records';
return `- Use present_records to add verified records as native clickable results in the chat without changing the current CMS view. Use it whenever record choices or references would help the editor, and do not repeat the same targets with another local record tool.
-- Use open_record for one verified saved record when the user explicitly asks to open or show it, or when one result is clearly primary. In the standalone inspector it can optionally focus a field. In a record sidebar, it can focus a field only when the target is the saved record already open; another saved record opens in the host's modal editor without field focus.${
+- Use open_record for one verified saved record when the user explicitly asks to open or show it, or when one result is clearly primary. In the standalone inspector it can optionally focus a field. In a record sidebar, it can focus a field only when the target is the saved record already open; another saved record opens in the host's modal editor without field focus.
+- Use open_record_in_full_editor when the editor asks for the standard record sidebar, Web Previews, or the full record page. It opens the normal DatoCMS editor in a new browser tab and leaves this chat in place. The inspector record pane cannot show that sidebar. Do not claim the tab opened unless the tool result says it did; if the browser blocked it, share the returned URL.${
inRecordSidebar && !hasSavedCurrentRecord
? ' This sidebar contains a new unsaved record, so use present_fields rather than open_record to reference its fields.'
: ''