feat(core): Show the app preview when the agent says so, stream agent replies as Markdown

- `apps show-preview` lifts the preview hold during the creating run; the run end stays the fallback
- `AgentChat.text()` throws `agent_suspended` when the turn ends on an approval request
- `markdown` catalog component renders agent replies with raw HTML escaped; `markdown-it` joins the template
- Skill docs put streaming first and forbid `v-html` on agent text
- Theme `mode` defaults to `system`
- Bind approval card shows one row per binding
This commit is contained in:
Elias Meire 2026-09-14 13:25:56 +02:00
parent 32b4929ddb
commit b2a450ca2f
No known key found for this signature in database
26 changed files with 691 additions and 242 deletions

View File

@ -363,15 +363,17 @@ describe('confirmationRequestPayloadSchema', () => {
it('preserves the app binding details of a workflow bind approval', () => {
const payload = makeConfirmation({
appBinding: {
kind: 'workflow',
appId: 'app-1',
appName: 'Runner',
appNamespace: 'runner',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
appBindings: [
{
kind: 'workflow',
appId: 'app-1',
appName: 'Runner',
appNamespace: 'runner',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
],
});
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
@ -379,17 +381,19 @@ describe('confirmationRequestPayloadSchema', () => {
it('preserves the app binding details of a data table bind approval', () => {
const payload = makeConfirmation({
appBinding: {
kind: 'dataTable',
appId: 'app-1',
appName: 'Board',
appNamespace: 'board',
dataTableId: 'dt-1',
dataTableName: 'Tasks',
key: 'tasks',
permissions: ['read', 'write'],
projectId: 'proj-1',
},
appBindings: [
{
kind: 'dataTable',
appId: 'app-1',
appName: 'Board',
appNamespace: 'board',
dataTableId: 'dt-1',
dataTableName: 'Tasks',
key: 'tasks',
permissions: ['read', 'write'],
projectId: 'proj-1',
},
],
});
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
@ -397,34 +401,46 @@ describe('confirmationRequestPayloadSchema', () => {
it('preserves the app binding details of an agent bind approval', () => {
const payload = makeConfirmation({
appBinding: {
kind: 'agent',
appId: 'app-1',
appName: 'Helpdesk',
appNamespace: 'helpdesk',
agentId: 'agent-1',
agentName: 'Support',
key: 'support',
permissions: ['chat', 'history'],
published: false,
projectId: 'proj-1',
},
appBindings: [
{
kind: 'agent',
appId: 'app-1',
appName: 'Helpdesk',
appNamespace: 'helpdesk',
agentId: 'agent-1',
agentName: 'Support',
key: 'support',
permissions: ['chat', 'history'],
published: false,
projectId: 'proj-1',
},
],
});
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
});
it('rejects an empty app bindings list', () => {
const result = confirmationRequestPayloadSchema.safeParse(
makeConfirmation({ appBindings: [] }),
);
expect(result.success).toBe(false);
});
it('rejects an app binding without a kind', () => {
const result = confirmationRequestPayloadSchema.safeParse(
makeConfirmation({
appBinding: {
appId: 'app-1',
appName: 'Runner',
appNamespace: 'runner',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
} as unknown as InstanceAiConfirmationRequestPayload['appBinding'],
appBindings: [
{
appId: 'app-1',
appName: 'Runner',
appNamespace: 'runner',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
] as unknown as InstanceAiConfirmationRequestPayload['appBindings'],
}),
);

View File

@ -34,7 +34,11 @@ export const appThemeModeSchema = z.enum(['light', 'dark', 'system']);
// What the Theme tab and the `apps` tool choose from. The server turns these
// into CSS variables so the contrast, tint and spacing maths lives in one place.
export const appThemeSettingsSchema = z.object({
mode: appThemeModeSchema,
mode: appThemeModeSchema
.default('system')
.describe(
'"system" follows the visitor\'s OS setting; pick "dark" only when the look asks for it',
),
primary: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Must be a 6-digit hex color, e.g. "#ff6900"'),
/** Corner radius in px; the template default is 4. */
radius: z.number().int().min(0).max(32).optional(),

View File

@ -853,7 +853,9 @@ export const confirmationRequestPayloadSchema = z.object({
webSearch: webSearchMetaSchema
.optional()
.describe('When present, renders web-search approval UI instead of generic confirm'),
appBinding: appBindingMetaSchema
appBindings: z
.array(appBindingMetaSchema)
.min(1)
.optional()
.describe('When present, renders the app binding approval UI instead of generic confirm'),
appBlueprint: appBlueprintSchema
@ -922,7 +924,7 @@ export function isDisplayableConfirmationRequest(
if (hasItems(payload.setupRequests)) return true;
if (hasItems(payload.credentialRequests)) return true;
if (payload.domainAccess) return true;
if (payload.appBinding) return true;
if (hasItems(payload.appBindings)) return true;
if (payload.channelConfig) return true;
if (payload.mcpConnectRequest) return true;

View File

@ -125,6 +125,24 @@ describe('agents', () => {
});
});
it('text() throws agent_suspended with the payload when the turn ends on an approval request', async () => {
const payload = { toolCallId: 'call-1', runId: 'run-1', toolName: 'send_email', input: {} };
fetchMock.mockResolvedValue(
sseResponse([
dataLine({ type: 'text-delta', id: 't', delta: 'Sure, sending' }),
dataLine({ type: 'tool-call-suspended', payload }),
]),
);
const error = await client()
.agents.support.chat('hi')
.text()
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(N8nAppError);
expect(error).toMatchObject({ status: 200, code: 'agent_suspended', issues: payload });
});
it('cancels the response body when the consumer stops before the stream ends', async () => {
const cancel = vi.fn();
const encoder = new TextEncoder();

View File

@ -197,7 +197,11 @@ export interface AgentChatOptions {
/** One turn of the agent. The request starts on the first read; a second pass yields nothing. */
export interface AgentChat extends AsyncIterable<AgentSseEvent> {
/** Drains the stream and returns the `text-delta` deltas joined; an `error` event throws `N8nAppError`. */
/**
* Drains the stream and returns the `text-delta` deltas joined. Throws `N8nAppError` on an
* `error` event, or with code `agent_suspended` when the agent asked for approval instead of
* answering: only iterating the stream can see and resume the suspension.
*/
text(): Promise<string>;
}
@ -397,6 +401,14 @@ function agentChat(start: () => Promise<Response>): AgentChat {
if (event.type === 'error') {
throw new N8nAppError(200, event.errorCode ?? 'execution_failed', event.message);
}
if (event.type === 'tool-call-suspended') {
throw new N8nAppError(
200,
'agent_suspended',
`The agent is waiting for approval of "${event.payload.toolName}"; iterate the chat stream to show and resume it`,
event.payload,
);
}
}
return text;
},

View File

@ -55,8 +55,9 @@ the conversation is already bound to an app.
that must survive a reload (`data-tables(action="list")`, or one you
will create); `agent`: a published agent (`agents(action="list")`).
Empty when the data stays in the browser.
- `theme`: `primary` hex, `mode`, and where the look asks for it `radius`
(px), `font`, `density` (`compact`|`comfortable`|`spacious`) and `tone`
- `theme`: `primary` hex, and where the look asks for it `mode` (default
`system`, which follows the visitor's OS), `radius` (px), `font`,
`density` (`compact`|`comfortable`|`spacious`) and `tone`
(`neutral`|`tinted`). Playful → warm primary, large radius, spacious,
tinted; minimal → gray or one cool primary, small radius, neutral; bold
→ saturated primary, tinted, dark mode.
@ -70,6 +71,10 @@ the conversation is already bound to an app.
values; `bind` each connection (creating a missing data table first);
write the pages and routes; write `BLUEPRINT.md` at the app root (name,
summary, pages, connections, look) so a later conversation can read it.
The preview stays hidden during this run until you call
`apps(action="show-preview", appId)`. Call it as soon as your own pages
have replaced the template's placeholder content, so the user watches the
rest take shape. The bar is "nothing generic left on screen", not "done".
## The loop
@ -201,10 +206,13 @@ for every bound key. Details of the types, warnings and `denied` reasons:
`agents(action="list")` shows the ids; the agent must be published or `chat`
fails with `agent_not_published`. `apps(action="bind", appId, bindings=[{ key:
"support", kind: "agent", agentId, permissions: ["chat", "history"] }])`, then
`n8n.agents.support.chat(message)` in the app. Visitors are anonymous, get
their own session and answer the agent's approval cards themselves:
`references/app-sdk.md` ("Agents") describes the chat loop, the approval
cards and `messages()` on load.
`n8n.agents.support.chat(message)` in the app. A chat UI iterates the stream
and appends each `text-delta` as it arrives; `.text()` is for a one-shot
answer only. Agents reply in Markdown: add the `markdown` catalog component
and render replies with `<Markdown :source>`, never `v-html`. Visitors are
anonymous, get their own session and answer the agent's approval cards
themselves: `references/app-sdk.md` ("Agents") describes the chat loop, the
approval cards and `messages()` on load.
## Template

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import { renderMarkdown } from './markdown';
const props = defineProps<{
source: string;
/** Render one line without a wrapping paragraph, e.g. inside a table cell. */
inline?: boolean;
class?: HTMLAttributes['class'];
}>();
const html = computed(() => renderMarkdown(props.source, props.inline));
</script>
<template>
<component
:is="inline ? 'span' : 'div'"
:class="
cn(
'text-sm leading-relaxed break-words',
'[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
'[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0',
'[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5',
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
'[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0',
'[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
'[&_hr]:my-4 [&_hr]:border-border',
'[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
'[&_img]:max-w-full [&_img]:rounded-md',
props.class,
)
"
v-html="html"
/>
</template>

View File

@ -0,0 +1,2 @@
export { default as Markdown } from './Markdown.vue';
export { renderMarkdown } from './markdown';

View File

@ -0,0 +1,16 @@
import MarkdownIt from 'markdown-it';
// `html: false` escapes raw HTML in the source, so agent output is safe to render.
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
const renderLink = md.renderer.rules.link_open ?? md.renderer.renderToken.bind(md.renderer);
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener noreferrer');
return renderLink(tokens, idx, options, env, self);
};
/** Markdown to sanitized HTML. `inline` skips the wrapping `<p>` for a single line. */
export function renderMarkdown(source: string, inline = false): string {
return inline ? md.renderInline(source) : md.render(source);
}

View File

@ -185,12 +185,10 @@ A filter is `{ type?: 'and' | 'or', filters: [{ columnName, condition?, value }]
import { n8n } from '@n8n/app-sdk';
import type { ToolSuspendedPayload } from '@n8n/app-sdk';
// One turn. The request starts on the first read; `text()` drains the stream
// and joins the `text-delta` events.
const reply = await n8n.agents.support.chat('How do I reset my password?').text();
// Streaming. Render the text as it arrives and keep the suspension, if any.
// `reply`, `error` and `pending` are the app's own reactive state.
// A chat UI: one turn, streamed. The request starts on the first read. Append
// each delta as it arrives and keep the suspension, if any. `reply`, `error`
// and `pending` are the app's own reactive state; render `reply` with
// `<Markdown :source="reply" />` from the catalog, since agents answer in Markdown.
let pending: ToolSuspendedPayload | undefined;
for await (const event of n8n.agents.support.chat(message)) {
if (event.type === 'text-delta') reply.value += event.delta;
@ -198,6 +196,11 @@ for await (const event of n8n.agents.support.chat(message)) {
if (event.type === 'error') error.value = event.message;
}
// A one-shot answer with no UI to stream into (a summary, a label): `text()`
// drains the stream and joins the `text-delta` events. It throws when the
// agent asks for approval instead of answering, so a chat UI streams instead.
const summary = await n8n.agents.support.chat('Summarize this ticket: …').text();
// The agent asked for approval: render a card with Approve / Deny and send the
// visitor's answer. `approved` is the button they clicked.
if (pending && isApproval(pending.input)) {
@ -218,10 +221,11 @@ const { messages, openSuspensions } = await n8n.agents.support.messages();
- `chat(message, opts?)` → `AgentChat`: an `AsyncIterable<AgentSseEvent>`
with `text(): Promise<string>`. One `AgentChat` is one request: iterate it
or call `text()`, not both. `text()` throws `N8nAppError` (`status: 200`,
`code` from the event or `execution_failed`) when the stream carries an
`error` event, so a failed turn never reads as an empty reply. `opts`:
`sessionId` (default `sessionId()`), `signal`.
or call `text()`, not both. `text()` throws `N8nAppError` (`status: 200`)
with the event's code or `execution_failed` when the stream carries an
`error` event, and with `agent_suspended` when the turn ends on
`tool-call-suspended`, so a failed or paused turn never reads as an empty
reply. `opts`: `sessionId` (default `sessionId()`), `signal`.
- The events are the same as the agent editor's stream: `text-start` /
`text-delta` / `text-end` (the reply, `delta` per event), `reasoning-*`,
`tool-input-*`, `tool-call` / `tool-execution-*` / `tool-result` (tool

View File

@ -60,6 +60,7 @@ then import each as `import { X } from '@/components/ui/<name>'`:
| Switch | `Switch` | `v-model` a boolean ref. |
| Checkbox | `Checkbox` | `v-model` a boolean ref. |
| Tooltip | `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider` | `TooltipProvider` is a plain passthrough (Ark UI needs no shared context) — wrap the tree in one only to match other libraries' convention if you want to. |
| Markdown | `Markdown`, `renderMarkdown` | `<Markdown :source="text" />` renders Markdown (agent replies, rich text) as styled, sanitized HTML: raw HTML in the source is escaped, links open in a new tab. `inline` drops the wrapping paragraph. `renderMarkdown(source, inline?)` returns the HTML string for the rare case a component needs it. Never `v-html` agent text. |
| DropdownMenu | `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSeparator`, `DropdownMenuGroup`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuSub`, `DropdownMenuSubTrigger` | For a submenu, nest a `DropdownMenuSub` inside the parent's `DropdownMenuContent`, with a `DropdownMenuSubTrigger` and its own `DropdownMenuContent` inside it. |
Every `Item`/`Trigger`/`CheckboxItem`/`RadioItem` that identifies one entry in

View File

@ -6,7 +6,7 @@ Stack: Vite, Vue 3, TypeScript, vue-router, Tailwind v4, this skill's own compon
- Do not start a dev server; n8n runs one for the live preview, and dependencies are installed at `create`. Every edit appears in the preview by itself (hot module reload). Never run a build to check your work. Never tell the user to publish, to open `/apps/<namespace>/` or to build to see a change: the preview beside the chat already shows it. Call `apps` (`action: "publish"`) only when the user asks to publish, deploy or share the app: the user confirms, then n8n builds the current source and updates `/apps/<namespace>/`. Preview errors arrive as context on the user's next message; fix them before anything else.
- The build sandbox has 512 MiB of memory. Publishing runs `npm run build` (`vite build` only) and serves `dist/`; keep type checking out of the build script. A new dependency needs `npm install` in the app directory by you.
- Workflows: the app calls n8n workflows only through `@n8n/app-sdk` (`import { n8n, N8nAppError } from '@n8n/app-sdk'`, `await n8n.workflows.run('<key>', input)`) and only for keys bound with `apps(action="bind")`. Bind first; the `bind` result lists each key with its input and output schema. Data tables work the same way: bind with `kind: "dataTable"`, then `n8n.tables.<key>.list/insert/update/delete`; the `bind` result lists the columns. Store data that must survive a reload in a data table, not in `localStorage` or an in-memory array. `vendor/n8n-app-sdk.tgz` and `src/n8n-bindings.d.ts` are generated; do not edit them. Never call `/rest`, `/webhook` or `/apps/*/api` by hand. All apps are public: the runtime API is callable from the app's own page only (CORS), but like a public webhook, anyone who can reach the instance can call a bound workflow, table or agent, so bind only ones that may be public; `result.principal` is `null`.
- Agents: the app chats with an n8n agent only through `n8n.agents.<key>` (`chat(message)` streams `AgentSseEvent`s, `.text()` joins the reply; `resume` answers a `tool-call-suspended` card; `messages()` restores the visitor's history) and only for keys bound with `kind: "agent"`. The agent must be published, or `chat` fails with `agent_not_published`. Visitors are anonymous: each gets their own session (`sessionId()`; the conversation survives a reload in the same tab), and they answer the agent's approval requests themselves, so render every `tool-call-suspended` with `input.type === "approval"` as an Approve / Deny card and call `resume({ runId, toolCallId, resumeData: { approved } })`.
- Agents: the app chats with an n8n agent only through `n8n.agents.<key>` (`chat(message)` streams `AgentSseEvent`s; `resume` answers a `tool-call-suspended` card; `messages()` restores the visitor's history) and only for keys bound with `kind: "agent"`. A chat UI iterates the stream (`for await`) and appends each `text-delta` as it arrives, so the visitor sees the reply grow; `.text()` waits for the whole turn and throws if the agent asks for approval, so use it only for a one-shot answer with no approval possible. Agents reply in Markdown: render it with `Markdown` from the catalog (`apps(action: "add-component", components: ["markdown"])`), never with `v-html`. The agent must be published, or `chat` fails with `agent_not_published`. Visitors are anonymous: each gets their own session (`sessionId()`; the conversation survives a reload in the same tab), and they answer the agent's approval requests themselves, so render every `tool-call-suspended` with `input.type === "approval"` as an Approve / Deny card and call `resume({ runId, toolCallId, resumeData: { approved } })`.
- Build UI from the components in `src/components/ui/`, e.g. `import { Button } from '@/components/ui/button'`. Need components that aren't there yet? Call `apps(action: "add-component", components: ["<name>", …])` once for all of them — do not hand-write a component the catalog already provides.
- Style everything with Tailwind utility classes reading the theme's CSS variables (`bg-primary`, `text-muted-foreground`, `rounded-lg`). No hex colors, no inline styles, no `dark:` variants (dark mode comes from the `.dark` class the theme mode sets).
- For interactive behavior a component doesn't already cover, use `@ark-ui/vue` primitives directly and style them with utilities and `data-[state=…]:` variants.

View File

@ -12,12 +12,14 @@
"@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"markdown-it": "^14.3.2",
"tailwind-merge": "^3.6.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/markdown-it": "^14.2.0",
"@vitejs/plugin-vue": "^5.2.4",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.4.0",
@ -1467,6 +1469,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -2627,6 +2654,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -3095,6 +3128,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -3104,6 +3156,51 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.3.2",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz",
"integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@ -3220,6 +3317,15 @@
"proxy-compare": "^3.0.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@ -3353,6 +3459,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uqr": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",

View File

@ -8,16 +8,18 @@
"typecheck": "vue-tsc -b"
},
"dependencies": {
"@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz",
"@ark-ui/vue": "^5.39.1",
"@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"markdown-it": "^14.3.2",
"tailwind-merge": "^3.6.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/markdown-it": "^14.2.0",
"@vitejs/plugin-vue": "^5.2.4",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.4.0",

View File

@ -571,14 +571,14 @@ describe('mapAgentChunkToEvent', () => {
type: 'tool-call-suspended',
toolCallId: 'tc-1',
toolName: 'apps',
suspendPayload: { requestId: 'request-1', message: 'Connect', appBinding: payload },
suspendPayload: { requestId: 'request-1', message: 'Connect', appBindings: payload },
});
expect(suspended(appBinding)).toMatchObject({
expect(suspended([appBinding])).toMatchObject({
type: 'confirmation-request',
payload: { message: 'Connect', appBinding },
payload: { message: 'Connect', appBindings: [appBinding] },
});
expect(suspended({ appId: 'app-1' })).not.toHaveProperty('payload.appBinding');
expect(suspended([{ appId: 'app-1' }])).not.toHaveProperty('payload.appBindings');
});
it('maps confirmations with a channelConfig payload', () => {

View File

@ -239,7 +239,10 @@ function parseSchemaArray<T>(value: unknown, schema: z.ZodType<T>): T[] | undefi
return parsed.length > 0 ? parsed : undefined;
}
function parseSchemaRecord<T>(value: unknown, schema: z.ZodType<T>): T | undefined {
function parseSchemaRecord<T>(
value: unknown,
schema: z.ZodType<T, z.ZodTypeDef, unknown>,
): T | undefined {
if (!isRecord(value)) return undefined;
const parsed = schema.safeParse(value);
return parsed.success ? parsed.data : undefined;
@ -343,7 +346,7 @@ function mapSuspendedChunk(
const planItems = parseSchemaArray(suspendPayload.planItems, plannedTaskArgSchema);
const domainAccess = parseDomainAccess(suspendPayload.domainAccess);
const webSearch = parseSchemaRecord(suspendPayload.webSearch, webSearchMetaSchema);
const appBinding = parseSchemaRecord(suspendPayload.appBinding, appBindingMetaSchema);
const appBindings = parseSchemaArray(suspendPayload.appBindings, appBindingMetaSchema);
const appBlueprint = parseSchemaRecord(suspendPayload.appBlueprint, appBlueprintSchema);
const credentialFlow = parseCredentialFlow(suspendPayload.credentialFlow);
const credentialDestination = parseSchemaRecord(
@ -394,7 +397,7 @@ function mapSuspendedChunk(
...(inputType ? { inputType } : {}),
...(domainAccess ? { domainAccess } : {}),
...(webSearch ? { webSearch } : {}),
...(appBinding ? { appBinding } : {}),
...(appBindings ? { appBindings } : {}),
...(appBlueprint ? { appBlueprint } : {}),
...(credentialFlow ? { credentialFlow } : {}),
...(credentialDestination ? { credentialDestination } : {}),

View File

@ -1546,8 +1546,15 @@ describe('apps tool', () => {
expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled();
});
it('suspends with plain text naming every workflow when several are bound at once', async () => {
it('suspends with one card row per workflow when several are bound at once', async () => {
const context = createMockContext();
appServiceMock(context, 'previewBindings').mockResolvedValue({
bindings: [
SUBMIT_BINDING,
{ ...SUBMIT_BINDING, key: 'notify', workflowId: 'wf-2', name: 'Notify' },
],
warnings: [],
});
const suspend = vi.fn().mockResolvedValue('suspended');
const result = await runAction(context, bindInput, { resumeData: undefined, suspend });
@ -1560,8 +1567,16 @@ describe('apps tool', () => {
'Connect workflow "Echo" (wf-2) to app "Greeter" as "notify" ' +
'(callable by anyone with the app URL)',
severity: 'warning',
appBindings: [
expect.objectContaining({ kind: 'workflow', workflowId: 'wf-1', key: 'submit' }),
expect.objectContaining({
kind: 'workflow',
workflowId: 'wf-2',
workflowName: 'Notify',
key: 'notify',
}),
],
});
expect(appServiceMock(context, 'previewBindings')).not.toHaveBeenCalled();
expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled();
});
@ -1584,15 +1599,17 @@ describe('apps tool', () => {
message:
'Connect workflow "Echo" (wf-1) to app "Greeter" as "submit" (callable by anyone with the app URL)',
severity: 'warning',
appBinding: {
kind: 'workflow',
appId: 'app-1',
appName: 'Greeter',
appNamespace: 'greeter',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
appBindings: [
{
kind: 'workflow',
appId: 'app-1',
appName: 'Greeter',
appNamespace: 'greeter',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
],
});
expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled();
});
@ -1611,7 +1628,7 @@ describe('apps tool', () => {
{ resumeData: undefined, suspend },
);
expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBinding');
expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBindings');
});
it('falls back to the workflow id when the workflow cannot be read', async () => {
@ -1682,22 +1699,24 @@ describe('apps tool', () => {
message:
'Connect data table "Tasks" (dt-1) to app "Greeter" as "tasks" with read and write access (anyone with the app URL gets this access)',
severity: 'warning',
appBinding: {
kind: 'dataTable',
appId: 'app-1',
appName: 'Greeter',
appNamespace: 'greeter',
dataTableId: 'dt-1',
dataTableName: 'Tasks',
key: 'tasks',
permissions: ['read', 'write'],
projectId: 'proj-1',
},
appBindings: [
{
kind: 'dataTable',
appId: 'app-1',
appName: 'Greeter',
appNamespace: 'greeter',
dataTableId: 'dt-1',
dataTableName: 'Tasks',
key: 'tasks',
permissions: ['read', 'write'],
projectId: 'proj-1',
},
],
});
expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled();
});
it('suspends with plain text naming every table when several are bound at once', async () => {
it('falls back to plain text when one of several tables is missing', async () => {
const context = createMockContext();
appServiceMock(context, 'previewBindings').mockResolvedValue({
bindings: [TASKS_BINDING, MISSING_BINDING],
@ -1799,23 +1818,25 @@ describe('apps tool', () => {
'Connect agent "Support" (agent-1) to app "Greeter" as "support" with chat and history access ' +
"(anyone with the app URL gets this access and answers the agent's approval requests)",
severity: 'warning',
appBinding: {
kind: 'agent',
appId: 'app-1',
appName: 'Greeter',
appNamespace: 'greeter',
agentId: 'agent-1',
agentName: 'Support',
key: 'support',
permissions: ['chat', 'history'],
published: false,
projectId: 'proj-1',
},
appBindings: [
{
kind: 'agent',
appId: 'app-1',
appName: 'Greeter',
appNamespace: 'greeter',
agentId: 'agent-1',
agentName: 'Support',
key: 'support',
permissions: ['chat', 'history'],
published: false,
projectId: 'proj-1',
},
],
});
expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled();
});
it('suspends with plain text naming every agent when several are bound at once', async () => {
it('falls back to plain text when the preview resolves none of several agents', async () => {
const context = withDelegate();
resolveAgentName.mockResolvedValueOnce('Support').mockResolvedValueOnce(undefined);
const suspend = vi.fn().mockResolvedValue('suspended');
@ -1832,7 +1853,6 @@ describe('apps tool', () => {
{ resumeData: undefined, suspend },
);
expect(appServiceMock(context, 'previewBindings')).not.toHaveBeenCalled();
expect(suspend).toHaveBeenCalledWith({
requestId: expect.any(String),
message:
@ -1850,7 +1870,7 @@ describe('apps tool', () => {
await runAction(context, bindInput, { resumeData: undefined, suspend });
expect(suspend.mock.calls[0][0].message).toContain('Connect agent "agent-1" (agent-1)');
expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBinding');
expect(suspend.mock.calls[0][0]).not.toHaveProperty('appBindings');
});
it('is denied when the user rejects the card', async () => {
@ -1950,6 +1970,17 @@ describe('apps tool', () => {
});
});
describe('show-preview', () => {
it('returns a marker without touching the app', async () => {
const context = createMockContext();
const result = await runAction(context, { action: 'show-preview' });
expect(result).toEqual({ appId: 'app-1', shown: true });
expect(appServiceMock(context, 'get')).not.toHaveBeenCalled();
});
});
describe('buildCheckScript', () => {
it('matches the snapshot', () => {
expect(

View File

@ -173,11 +173,17 @@ const bindingsSchema = z.object({
appId: z.string(),
});
/** A marker for the editor: the call in the run's agent tree lifts the hold on the frame. */
const showPreviewSchema = z.object({
action: z.literal('show-preview'),
appId: z.string(),
});
const confirmationSuspendSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
appBinding: appBindingMetaSchema.optional(),
appBindings: z.array(appBindingMetaSchema).min(1).optional(),
});
interface ConfirmationToolContext {
@ -199,13 +205,15 @@ type BindPermission = 'bindAppWorkflow' | 'bindAppDataTable' | 'bindAppAgent';
const DEFAULT_AGENT_PERMISSIONS: AgentBinding['permissions'] = ['chat', 'history'];
type UnbindInput = z.infer<typeof unbindSchema>;
type BindingsInput = z.infer<typeof bindingsSchema>;
type ShowPreviewInput = z.infer<typeof showPreviewSchema>;
type AppsInput =
| CreateInput
| PublishInput
| AddComponentInput
| BindInput
| UnbindInput
| BindingsInput;
| BindingsInput
| ShowPreviewInput;
/** Not a tool action: n8n's publish pipeline calls `handleBuild` directly. */
interface BuildInput {
@ -1071,7 +1079,7 @@ async function requireBindApproval(
context: InstanceAiContext,
ctx: ConfirmationToolContext,
permission: BindPermission,
describe: () => Promise<{ message: string; appBinding?: AppBindingMeta }>,
describe: () => Promise<BindDescription>,
): Promise<AppActionDenied | null> {
const mode = context.permissions?.[permission];
if (mode === 'blocked') {
@ -1093,7 +1101,22 @@ async function requireBindApproval(
}
type BoundApp = Omit<AppSummary, 'createdAt'>;
type BindDescription = { message: string; appBinding?: AppBindingMeta };
// The card shows one row per binding; a binding the preview cannot resolve (the write
// refuses it with the reason) drops the card and leaves the plain text.
type BindDescription = { message: string; appBindings?: AppBindingMeta[] };
function describeBindings<T extends DescribedBinding>(
bindings: AppBinding[],
described: DescribedBinding[],
is: (binding: DescribedBinding) => binding is T,
toMeta: (binding: T) => AppBindingMeta,
): Pick<BindDescription, 'appBindings'> {
const resolved = bindings.flatMap((binding) => {
const match = described.find((candidate) => candidate.key === binding.key);
return match && is(match) ? [match] : [];
});
return resolved.length === bindings.length ? { appBindings: resolved.map(toMeta) } : {};
}
/** The model passes one flat object per binding; the service wants the discriminated union. */
function toAppBinding(flat: FlatBinding): AppBinding | AppActionDenied {
@ -1144,22 +1167,18 @@ async function describeWorkflowBind(
);
// One line: the card renders the message as plain HTML text, which folds newlines.
const message = `${lines.join('; ')} (callable by anyone with the app URL)`;
// The structured card shows one binding. A multi-binding call, or a workflow the
// preview cannot resolve (the write refuses it with the reason), gets the plain text.
const [described] =
bindings.length === 1 ? (await appService.previewBindings(app.id, bindings)).bindings : [];
if (!described || !isWorkflowBinding(described)) return { message };
const described = (await appService.previewBindings(app.id, bindings)).bindings;
return {
message,
appBinding: {
...describeBindings(bindings, described, isWorkflowBinding, (binding) => ({
kind: 'workflow',
appId: app.id,
appName: app.name,
appNamespace: app.namespace,
workflowId: described.workflowId,
workflowName: described.name,
key: described.key,
},
workflowId: binding.workflowId,
workflowName: binding.name,
key: binding.key,
})),
};
}
@ -1176,21 +1195,19 @@ async function describeDataTableBind(
`Connect data table "${nameOf(key)}" (${dataTableId}) to app "${app.name}" as "${key}" with ${permissions.join(' and ')} access`,
);
const message = `${lines.join('; ')} (anyone with the app URL gets this access)`;
const [only] = described;
if (bindings.length !== 1 || !only || !isDataTableBinding(only)) return { message };
return {
message,
appBinding: {
...describeBindings(bindings, described, isDataTableBinding, (binding) => ({
kind: 'dataTable',
appId: app.id,
appName: app.name,
appNamespace: app.namespace,
dataTableId: only.dataTableId,
dataTableName: only.name,
key: only.key,
permissions: only.permissions,
dataTableId: binding.dataTableId,
dataTableName: binding.name,
key: binding.key,
permissions: binding.permissions,
projectId: app.projectId,
},
})),
};
}
@ -1213,25 +1230,21 @@ async function describeAgentBind(
);
// Visitors are anonymous: the app forwards the agent's approval requests to them.
const message = `${lines.join('; ')} (anyone with the app URL gets this access and answers the agent's approval requests)`;
const [described] =
bindings.length === 1
? (await requireAppService(context).previewBindings(app.id, bindings)).bindings
: [];
if (!described || !isAgentBinding(described)) return { message };
const described = (await requireAppService(context).previewBindings(app.id, bindings)).bindings;
return {
message,
appBinding: {
...describeBindings(bindings, described, isAgentBinding, (binding) => ({
kind: 'agent',
appId: app.id,
appName: app.name,
appNamespace: app.namespace,
agentId: described.agentId,
agentName: described.name,
key: described.key,
permissions: described.permissions,
published: described.published,
agentId: binding.agentId,
agentName: binding.name,
key: binding.key,
permissions: binding.permissions,
published: binding.published,
projectId: app.projectId,
},
})),
};
}
@ -1369,6 +1382,7 @@ export function createAppsTool(context: InstanceAiContext) {
bindSchema,
unbindSchema,
bindingsSchema,
showPreviewSchema,
]),
);
@ -1380,7 +1394,8 @@ export function createAppsTool(context: InstanceAiContext) {
'`publish` asks the user to confirm, then builds the current source and updates /apps/<namespace>/; returns `url`, `{ denied }`, or `{ error, stage, message, log }`. ' +
'`add-component` copies catalog components (references/design-system.md) into src/components/ui/; returns the names added. ' +
'`bind` connects workflows (`{ key, kind: "workflow", workflowId }`), data tables (`{ key, kind: "dataTable", dataTableId, permissions }`) or published agents (`{ key, kind: "agent", agentId, permissions }`), one kind per call, after user approval; returns every binding with its types. ' +
'`unbind` removes a key; `bindings` lists them. All three rewrite src/n8n-bindings.d.ts.',
'`unbind` removes a key; `bindings` lists them. All three rewrite src/n8n-bindings.d.ts. ' +
'`show-preview` reveals the live preview during the run that creates the app. Call it once the template is gone and your own app shows, not when it is finished.',
)
.input(inputSchema)
.suspend(confirmationSuspendSchema)
@ -1399,6 +1414,8 @@ export function createAppsTool(context: InstanceAiContext) {
return await handleUnbind(context, input, ctx.abortSignal);
case 'bindings':
return await handleBindings(context, input, ctx.abortSignal);
case 'show-preview':
return { appId: input.appId, shown: true };
}
})
.build();

View File

@ -47,14 +47,14 @@ const OPTIONS: ApprovalOption[] = [
];
const renderComponent = createComponentRenderer(AppBindingApproval, {
props: { appBinding: APP_BINDING, options: OPTIONS },
props: { appBindings: [APP_BINDING], options: OPTIONS },
});
describe('AppBindingApproval', () => {
it('shows the app, then the workflow linked in a new tab, and nothing else', () => {
const { getByTestId, getByText, queryByText } = renderComponent();
expect(getByText('Allow AI Assistant to connect a workflow to the app?')).toBeInTheDocument();
expect(getByText('Allow n8n Assistant to connect a workflow to the app?')).toBeInTheDocument();
const app = getByTestId('instance-ai-app-binding-app');
const link = getByTestId('instance-ai-app-binding-workflow');
expect(app).toHaveTextContent('Runner');
@ -76,10 +76,12 @@ describe('AppBindingApproval', () => {
it('shows the app, the data table linked in a new tab, and the read-write access line', () => {
const { getByTestId, getByText, queryByTestId, queryByText } = renderComponent({
props: { appBinding: DATA_TABLE_BINDING },
props: { appBindings: [DATA_TABLE_BINDING] },
});
expect(getByText('Allow AI Assistant to connect a data table to the app?')).toBeInTheDocument();
expect(
getByText('Allow n8n Assistant to connect a data table to the app?'),
).toBeInTheDocument();
expect(getByTestId('instance-ai-app-binding-app')).toHaveTextContent('Board');
const link = getByTestId('instance-ai-app-binding-data-table');
expect(link).toHaveTextContent('Tasks');
@ -92,9 +94,39 @@ describe('AppBindingApproval', () => {
expect(queryByText(/tasks/)).toBeNull();
});
it('shows one row with its own access line per data table when several are bound', () => {
const { getAllByTestId, getAllByText } = renderComponent({
props: {
appBindings: [
{
...DATA_TABLE_BINDING,
key: 'sessions',
dataTableName: 'Sessions',
permissions: ['read'],
},
{ ...DATA_TABLE_BINDING, key: 'agenda', dataTableId: 'dt-2', dataTableName: 'Agenda' },
],
},
});
expect(getAllByText('Allow n8n Assistant to connect a data table to the app?')).toHaveLength(1);
expect(
getAllByTestId('instance-ai-app-binding-app').map((el) => el.textContent?.trim()),
).toEqual(['Board', 'Board']);
const links = getAllByTestId('instance-ai-app-binding-data-table');
expect(links.map((el) => el.textContent?.trim())).toEqual(['Sessions', 'Agenda']);
expect(links[1]).toHaveAttribute('href', '/projects/proj-1/datatables/dt-2');
expect(
getAllByTestId('instance-ai-app-binding-access').map((el) => el.textContent?.trim()),
).toEqual([
'Anyone who can open the app can read this table.',
'Anyone who can open the app can read and change rows in this table.',
]);
});
it('shows the read-only access line for a read-only data table binding', () => {
const { getByTestId } = renderComponent({
props: { appBinding: { ...DATA_TABLE_BINDING, permissions: ['read'] } },
props: { appBindings: [{ ...DATA_TABLE_BINDING, permissions: ['read'] }] },
});
expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent(
@ -104,7 +136,7 @@ describe('AppBindingApproval', () => {
it('shows the write-only access line for a data table binding without read', () => {
const { getByTestId } = renderComponent({
props: { appBinding: { ...DATA_TABLE_BINDING, permissions: ['write'] } },
props: { appBindings: [{ ...DATA_TABLE_BINDING, permissions: ['write'] }] },
});
expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent(
@ -114,7 +146,7 @@ describe('AppBindingApproval', () => {
it('emits the selected option key for a data table binding', async () => {
const { getByTestId, emitted } = renderComponent({
props: { appBinding: DATA_TABLE_BINDING },
props: { appBindings: [DATA_TABLE_BINDING] },
});
await userEvent.click(getByTestId('opt-always-allow'));
@ -124,10 +156,10 @@ describe('AppBindingApproval', () => {
it('shows the app, the agent linked in a new tab, the chat-and-history access line and the approvals line', () => {
const { getByTestId, getByText, queryByTestId, queryByText } = renderComponent({
props: { appBinding: AGENT_BINDING },
props: { appBindings: [AGENT_BINDING] },
});
expect(getByText('Allow AI Assistant to connect an agent to the app?')).toBeInTheDocument();
expect(getByText('Allow n8n Assistant to connect an agent to the app?')).toBeInTheDocument();
expect(getByTestId('instance-ai-app-binding-app')).toHaveTextContent('Helpdesk');
const link = getByTestId('instance-ai-app-binding-agent');
expect(link).toHaveTextContent('Support');
@ -151,7 +183,7 @@ describe('AppBindingApproval', () => {
[['history'], 'Visitors can read back their conversation with this agent.'],
] as const)('shows the access line for agent permissions %j', (permissions, text) => {
const { getByTestId } = renderComponent({
props: { appBinding: { ...AGENT_BINDING, permissions: [...permissions] } },
props: { appBindings: [{ ...AGENT_BINDING, permissions: [...permissions] }] },
});
expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent(text);
@ -159,7 +191,7 @@ describe('AppBindingApproval', () => {
it('warns when the agent is not published', () => {
const { getByTestId } = renderComponent({
props: { appBinding: { ...AGENT_BINDING, published: false } },
props: { appBindings: [{ ...AGENT_BINDING, published: false }] },
});
expect(getByTestId('instance-ai-app-binding-not-published')).toHaveTextContent(

View File

@ -571,15 +571,17 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
requestId: 'req-bind',
severity: 'warning',
message: 'Connect workflow "Echo" (wf-1) to app "Runner" as "submit"',
appBinding: {
kind: 'workflow',
appId: 'app-1',
appName: 'Runner',
appNamespace: 'runner',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
appBindings: [
{
kind: 'workflow',
appId: 'app-1',
appName: 'Runner',
appNamespace: 'runner',
workflowId: 'wf-1',
workflowName: 'Echo',
key: 'submit',
},
],
},
{ action: 'bind', appId: 'app-1' },
'apps',

View File

@ -13,6 +13,7 @@ import {
isAgentEditingWorkflow,
isAgentEditingAgent,
isAgentBuildingApp,
isAppPreviewShownIn,
getLatestAppSourceEditId,
} from '../canvasPreview.utils';
@ -1433,6 +1434,29 @@ describe('isAgentBuildingApp', () => {
});
});
describe('isAppPreviewShownIn', () => {
const showCall = (appId: string, isLoading = true) =>
makeToolCall({ toolName: 'apps', args: { action: 'show-preview', appId }, isLoading });
test('is true for an in-flight or completed show-preview call for the app, on any node', () => {
expect(isAppPreviewShownIn(makeAgentNode({ toolCalls: [showCall('app-1')] }), 'app-1')).toBe(
true,
);
const child = makeAgentNode({ agentId: 'agent-2', toolCalls: [showCall('app-1', false)] });
expect(isAppPreviewShownIn(makeAgentNode({ children: [child] }), 'app-1')).toBe(true);
});
test('is false for another app or another apps action', () => {
const node = makeAgentNode({
toolCalls: [
showCall('app-2'),
makeToolCall({ toolName: 'apps', args: { action: 'bindings', appId: 'app-1' } }),
],
});
expect(isAppPreviewShownIn(node, 'app-1')).toBe(false);
});
});
describe('getLatestAppSourceEditId', () => {
const target = { appId: 'app-1', namespace: 'greeter' };
const write = (

View File

@ -506,6 +506,17 @@ export function isAppCreatedIn(node: InstanceAiAgentNode, appId: string): boolea
return node.children.some((child) => isAppCreatedIn(child, appId));
}
/** Whether an `apps show-preview` call for `appId` is in this agent tree. The call itself is the signal, so in-flight counts. */
export function isAppPreviewShownIn(node: InstanceAiAgentNode, appId: string): boolean {
for (const tc of node.toolCalls) {
const args = tc.args as { action?: string; appId?: string } | undefined;
if (tc.toolName === 'apps' && args?.action === 'show-preview' && args.appId === appId) {
return true;
}
}
return node.children.some((child) => isAppPreviewShownIn(child, appId));
}
function getAgentTarget(node: InstanceAiAgentNode): AgentArtifactTarget | undefined {
if (node.targetResource?.type !== 'agent' || typeof node.targetResource.id !== 'string') {
return undefined;

View File

@ -5,13 +5,16 @@ import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import ApprovalOptionList, { type ApprovalOption } from './ApprovalOptionList.vue';
const props = defineProps<{ appBinding: AppBindingMeta; options: ApprovalOption[] }>();
const props = defineProps<{ appBindings: AppBindingMeta[]; options: ApprovalOption[] }>();
const emit = defineEmits<{ select: [key: string] }>();
const i18n = useI18n();
// One call binds one kind, so the first binding names the prompt for all of them.
const kind = computed(() => props.appBindings[0]?.kind);
const promptKey = computed(() => {
switch (props.appBinding.kind) {
switch (kind.value) {
case 'dataTable':
return 'instanceAi.appBinding.dataTable.prompt';
case 'agent':
@ -21,8 +24,7 @@ const promptKey = computed(() => {
}
});
const accessKey = computed(() => {
const binding = props.appBinding;
function accessKey(binding: AppBindingMeta) {
if (binding.kind === 'dataTable') {
return binding.permissions.includes('read')
? binding.permissions.includes('write')
@ -38,7 +40,7 @@ const accessKey = computed(() => {
: 'instanceAi.appBinding.agent.access.history';
}
return null;
});
}
</script>
<template>
@ -47,71 +49,78 @@ const accessKey = computed(() => {
<N8nText tag="div" size="medium" bold>
{{ i18n.baseText(promptKey) }}
</N8nText>
<div :class="$style.row">
<N8nIcon icon="app-window" size="large" />
<N8nText size="small" bold data-test-id="instance-ai-app-binding-app">
{{ appBinding.appName }}
</N8nText>
<N8nIcon icon="arrow-right" size="small" color="text-light" />
<template v-if="appBinding.kind === 'dataTable'">
<N8nIcon icon="table" size="large" />
<N8nLink
:to="`/projects/${appBinding.projectId}/datatables/${appBinding.dataTableId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-data-table"
>
{{ appBinding.dataTableName }}
</N8nLink>
</template>
<template v-else-if="appBinding.kind === 'agent'">
<N8nIcon icon="robot" size="large" />
<N8nLink
:to="`/projects/${appBinding.projectId}/agents/${appBinding.agentId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-agent"
>
{{ appBinding.agentName }}
</N8nLink>
<N8nText
v-if="!appBinding.published"
size="small"
color="warning"
data-test-id="instance-ai-app-binding-not-published"
>
{{ i18n.baseText('apps.connections.agent.notPublished') }}
<div
v-for="binding in appBindings"
:key="binding.key"
:class="$style.binding"
data-test-id="instance-ai-app-binding"
>
<div :class="$style.row">
<N8nIcon icon="app-window" size="large" />
<N8nText size="small" bold data-test-id="instance-ai-app-binding-app">
{{ binding.appName }}
</N8nText>
</template>
<template v-else>
<N8nIcon icon="workflow" size="large" />
<N8nLink
:to="`/workflow/${appBinding.workflowId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-workflow"
>
{{ appBinding.workflowName }}
</N8nLink>
</template>
<N8nIcon icon="arrow-right" size="small" color="text-light" />
<template v-if="binding.kind === 'dataTable'">
<N8nIcon icon="table" size="large" />
<N8nLink
:to="`/projects/${binding.projectId}/datatables/${binding.dataTableId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-data-table"
>
{{ binding.dataTableName }}
</N8nLink>
</template>
<template v-else-if="binding.kind === 'agent'">
<N8nIcon icon="robot" size="large" />
<N8nLink
:to="`/projects/${binding.projectId}/agents/${binding.agentId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-agent"
>
{{ binding.agentName }}
</N8nLink>
<N8nText
v-if="!binding.published"
size="small"
color="warning"
data-test-id="instance-ai-app-binding-not-published"
>
{{ i18n.baseText('apps.connections.agent.notPublished') }}
</N8nText>
</template>
<template v-else>
<N8nIcon icon="workflow" size="large" />
<N8nLink
:to="`/workflow/${binding.workflowId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-workflow"
>
{{ binding.workflowName }}
</N8nLink>
</template>
</div>
<N8nText
v-if="accessKey(binding)"
tag="div"
size="small"
color="text-light"
data-test-id="instance-ai-app-binding-access"
>
{{ i18n.baseText(accessKey(binding)!) }}
</N8nText>
</div>
<N8nText
v-if="accessKey"
tag="div"
size="small"
color="text-light"
data-test-id="instance-ai-app-binding-access"
>
{{ i18n.baseText(accessKey) }}
</N8nText>
<N8nText
v-if="appBinding.kind === 'agent'"
v-if="kind === 'agent'"
tag="div"
size="small"
color="text-light"
@ -133,6 +142,12 @@ const accessKey = computed(() => {
gap: var(--spacing--2xs);
}
.binding {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.row {
display: flex;
align-items: center;

View File

@ -7,7 +7,11 @@ import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
import AppDetailsView from '@/features/apps/AppDetailsView.vue';
import { useAppLivePreview } from '@/features/apps/composables/useAppLivePreview';
import type { App } from '@/features/apps/apps.types';
import { getLatestAppSourceEditId, isAppCreatedIn } from '../canvasPreview.utils';
import {
getLatestAppSourceEditId,
isAppCreatedIn,
isAppPreviewShownIn,
} from '../canvasPreview.utils';
import { getAppBuilderTargetFromThreadMetadata } from '../instanceAi.threadRuntime';
import { useThread, useInstanceAiStore } from '../instanceAi.store';
import { INSTANCE_AI_APP_BUILDER_TARGET_METADATA_KEY } from '../constants';
@ -61,17 +65,22 @@ const live = useAppLivePreview(
// The run that creates the app starts from the scaffold, and a theme write
// stores a source before the home page is touched; the dev server would show
// the starter template until then. Hold the frame until that run ends. A
// thread reopened on an existing app has no create in its active run.
// the starter template until then. Hold the frame until the agent says the
// template is gone (`apps show-preview`), or until that run ends. A thread
// reopened on an existing app has no create in its active run.
const creatingRun = computed(() => {
const runId = thread.activeRunId;
if (runId === null) return false;
return thread.messages.some(
(m) =>
m.role === 'assistant' &&
(m.runId === runId || (m.runIds?.includes(runId) ?? false)) &&
m.agentTree !== undefined &&
isAppCreatedIn(m.agentTree, props.appId),
const trees = thread.messages.flatMap((m) =>
m.role === 'assistant' &&
(m.runId === runId || (m.runIds?.includes(runId) ?? false)) &&
m.agentTree !== undefined
? [m.agentTree]
: [],
);
return (
trees.some((tree) => isAppCreatedIn(tree, props.appId)) &&
!trees.some((tree) => isAppPreviewShownIn(tree, props.appId))
);
});
const liveUrl = computed(() => (creatingRun.value ? undefined : live.liveUrl.value));

View File

@ -654,8 +654,8 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
<!-- App binding -->
<AppBindingApproval
v-else-if="chunk.item.toolCall.confirmation.appBinding"
:app-binding="chunk.item.toolCall.confirmation.appBinding!"
v-else-if="chunk.item.toolCall.confirmation.appBindings"
:app-bindings="chunk.item.toolCall.confirmation.appBindings!"
:options="buildApprovalOptions(chunk.item)"
@select="(key) => handleApprovalSelect(chunk.item, key)"
/>

View File

@ -20,14 +20,30 @@ const appEdit = (toolCallId: string, path: string): InstanceAiAgentNode => ({
timeline: [],
});
const appsCall = (
toolCallId: string,
args: Record<string, unknown>,
result?: unknown,
): InstanceAiAgentNode => ({
agentId: 'agent-1',
role: 'orchestrator',
status: 'active',
textContent: '',
reasoning: '',
toolCalls: [{ toolCallId, toolName: 'apps', args, isLoading: result === undefined, result }],
children: [],
timeline: [],
});
const threadState = reactive({
id: 'thread-1',
isStreaming: false,
activeRunId: null as string | null,
producedArtifacts: new Map<
string,
{ type: string; id: string; name: string; projectId: string; namespace?: string }
>([['app-1', { type: 'app', id: 'app-1', name: 'Greeter', projectId: 'proj-1' }]]),
messages: [] as Array<{ agentTree?: InstanceAiAgentNode }>,
messages: [] as Array<{ agentTree?: InstanceAiAgentNode; role?: string; runId?: string }>,
});
const metadataState = ref<Record<string, unknown>>();
const updateThreadMetadataMock = vi.fn(
@ -127,6 +143,7 @@ describe('InstanceAiAppPreview', () => {
settledCount.value = 0;
previewAhead.value = false;
threadState.messages = [];
threadState.activeRunId = null;
threadState.producedArtifacts.get('app-1')!.namespace = undefined;
markPreviewPublished.mockClear();
updateThreadMetadataMock.mockClear();
@ -272,6 +289,58 @@ describe('InstanceAiAppPreview', () => {
expect(latestSourceEditId.value).toBe('tc-1');
});
describe('during the run that creates the app', () => {
const ready = {
status: 'ready',
url: '/apps-preview/tok/',
expiresAt: '2026-09-09T00:00:00Z',
} as const;
const created = {
agentTree: appsCall('tc-create', { action: 'create' }, { app: { id: 'app-1' } }),
};
beforeEach(() => {
liveStatus.value = ready;
threadState.activeRunId = 'run-1';
});
it('holds the frame as starting until the run ends', async () => {
threadState.messages = [{ role: 'assistant', runId: 'run-1', ...created }];
const wrapper = mountPreview();
const view = wrapper.get('[data-test-id="app-details-view-stub"]');
expect(view.attributes('data-live-status')).toBe('starting');
expect(view.attributes('data-live-url')).toBeUndefined();
threadState.activeRunId = null;
await flushPromises();
expect(view.attributes('data-live-url')).toBe('/apps-preview/tok/');
});
it('releases the frame once the agent calls show-preview for this app', async () => {
threadState.messages = [{ role: 'assistant', runId: 'run-1', ...created }];
const wrapper = mountPreview();
const view = wrapper.get('[data-test-id="app-details-view-stub"]');
threadState.messages.push({
role: 'assistant',
runId: 'run-1',
agentTree: appsCall('tc-show', { action: 'show-preview', appId: 'app-2' }),
});
await flushPromises();
expect(view.attributes('data-live-url')).toBeUndefined();
threadState.messages.push({
role: 'assistant',
runId: 'run-1',
agentTree: appsCall('tc-show-2', { action: 'show-preview', appId: 'app-1' }),
});
await flushPromises();
expect(view.attributes('data-live-status')).toBe('ready');
expect(view.attributes('data-live-url')).toBe('/apps-preview/tok/');
});
});
it('shows the building indicator while an apps build call is in flight', () => {
const wrapper = mountPreview({ versionId: 'v-1', building: true });