From 1f474ffac20261cc368243197937646ba6cb01d2 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Wed, 9 Sep 2026 16:34:29 +0200 Subject: [PATCH] feat: Derive app pages from the built app's routes (no-changelog) (#38191) --- packages/@n8n/api-types/src/index.ts | 2 + .../@n8n/api-types/src/schemas/app.schema.ts | 10 + packages/cli/package.json | 2 +- .../__tests__/app-version.service.test.ts | 39 +++ .../apps/__tests__/derive-routes.test.ts | 136 +++++++++ .../src/modules/apps/app-version.service.ts | 53 +++- .../cli/src/modules/apps/apps.controller.ts | 11 + packages/cli/src/modules/apps/apps.service.ts | 12 + .../cli/src/modules/apps/derive-routes.ts | 146 +++++++++ .../frontend/@n8n/i18n/src/locales/en.json | 21 +- .../ai/instanceAi/InstanceAiThreadView.vue | 12 + .../components/InstanceAiAppPreview.vue | 3 + .../composables/useInstanceAiHandoff.ts | 2 + .../src/features/apps/AppDetailsView.test.ts | 48 +++ .../src/features/apps/AppDetailsView.vue | 84 ++++-- .../editor-ui/src/features/apps/PageCard.vue | 44 ++- .../src/features/apps/PageView.test.ts | 141 +++++++++ .../editor-ui/src/features/apps/PageView.vue | 279 ++++++++++-------- .../editor-ui/src/features/apps/apps.api.ts | 69 +---- .../src/features/apps/apps.constants.ts | 1 - .../editor-ui/src/features/apps/apps.store.ts | 63 +--- .../editor-ui/src/features/apps/apps.types.ts | 21 +- .../features/apps/components/AddPageModal.vue | 125 -------- .../apps/components/AppPreviewFrame.vue | 7 +- .../editor-ui/src/features/apps/modals.ts | 7 +- .../src/features/apps/pageTree.utils.test.ts | 50 ++++ .../src/features/apps/pageTree.utils.ts | 45 ++- .../src/features/apps/useAppDeletion.ts | 51 +--- .../src/features/apps/useAppPageAssistant.ts | 58 ++++ pnpm-lock.yaml | 6 +- 30 files changed, 1060 insertions(+), 488 deletions(-) create mode 100644 packages/cli/src/modules/apps/__tests__/derive-routes.test.ts create mode 100644 packages/cli/src/modules/apps/derive-routes.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/PageView.test.ts delete mode 100644 packages/frontend/editor-ui/src/features/apps/components/AddPageModal.vue create mode 100644 packages/frontend/editor-ui/src/features/apps/pageTree.utils.test.ts create mode 100644 packages/frontend/editor-ui/src/features/apps/useAppPageAssistant.ts diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 941c43b867f..020754feaac 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -187,6 +187,8 @@ export { type AppVersion, appThemeSchema, type AppTheme, + appRouteSchema, + type AppRoute, } from './schemas/app.schema'; export { diff --git a/packages/@n8n/api-types/src/schemas/app.schema.ts b/packages/@n8n/api-types/src/schemas/app.schema.ts index 0fd1ade1a34..3434ed3a352 100644 --- a/packages/@n8n/api-types/src/schemas/app.schema.ts +++ b/packages/@n8n/api-types/src/schemas/app.schema.ts @@ -58,3 +58,13 @@ export const appVersionSchema = z.object({ }); export type AppVersion = z.infer; + +// A page derived from the app's own `src/router.ts`, not a `Page` DB row — +// `id` is synthesized from the route's own full path, since there is no row. +export const appRouteSchema = z.object({ + id: z.string(), + parentPageId: z.string().nullable(), + route: z.string(), +}); + +export type AppRoute = z.infer; diff --git a/packages/cli/package.json b/packages/cli/package.json index ba8fe6c99a0..1d3da2d492f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -119,7 +119,6 @@ "tsconfig-paths": "^4.2.0", "@vitest/coverage-v8": "catalog:", "@typescript/native": "catalog:typescript-tooling", - "typescript": "catalog:typescript-tooling", "vite": "catalog:", "vitest": "catalog:", "vitest-mock-extended": "catalog:" @@ -279,6 +278,7 @@ "swagger-ui-express": "5.0.1", "tar": "catalog:", "turndown": "catalog:", + "typescript": "catalog:typescript-tooling", "uuid": "catalog:", "validator": "13.15.22", "ws": "8.20.1", diff --git a/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts b/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts index 411bc048ae4..83db498a76e 100644 --- a/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts +++ b/packages/cli/src/modules/apps/__tests__/app-version.service.test.ts @@ -5,8 +5,10 @@ import { Header } from 'tar'; import { mock } from 'vitest-mock-extended'; import type { AppVersionBlobStore } from '../app-version-blob-store'; +import type { AppVersion } from '../app-version.entity'; import type { AppVersionRepository } from '../app-version.repository'; import { AppVersionService } from '../app-version.service'; +import type { App } from '../app.entity'; import type { AppRepository } from '../app.repository'; import { AppBlobSizeQuotaExceededError } from '../errors/app-blob-size-quota-exceeded.error'; import { AppVersionQuotaExceededError } from '../errors/app-version-quota-exceeded.error'; @@ -161,4 +163,41 @@ describe('AppVersionService', () => { ); }); }); + + describe('readRouterSource', () => { + const app = { id: 'app-1', activeVersionId: 'v1' } as App; + const version = { + id: 'v1', + appId: 'app-1', + storedAt: 'db' as const, + sourceStorageKey: 'src-key', + distStorageKey: null, + } as AppVersion; + + it("returns the active version's src/router.ts", async () => { + appVersionRepository.findById.mockResolvedValue(version); + blobStore.readAsBuffer.mockResolvedValue( + tgz({ './src/router.ts': 'export const router = createRouter({ routes: [] });' }), + ); + + const result = await service.readRouterSource(app); + + expect(result).toContain('createRouter'); + }); + + it('returns null when the app has no version yet', async () => { + appVersionRepository.listByAppId.mockResolvedValue([]); + + expect( + await service.readRouterSource({ id: 'app-1', activeVersionId: null } as App), + ).toBeNull(); + }); + + it('returns null when the source has no src/router.ts', async () => { + appVersionRepository.findById.mockResolvedValue(version); + blobStore.readAsBuffer.mockResolvedValue(tgz({ './package.json': '{}' })); + + expect(await service.readRouterSource(app)).toBeNull(); + }); + }); }); diff --git a/packages/cli/src/modules/apps/__tests__/derive-routes.test.ts b/packages/cli/src/modules/apps/__tests__/derive-routes.test.ts new file mode 100644 index 00000000000..2f85af648b5 --- /dev/null +++ b/packages/cli/src/modules/apps/__tests__/derive-routes.test.ts @@ -0,0 +1,136 @@ +import { deriveRoutesFromRouterSource } from '../derive-routes'; + +/** The id scheme is an implementation detail; tests key off the tree shape instead. */ +function byRoute(routes: ReturnType, route: string) { + const page = routes.find((r) => r.route === route); + if (!page) throw new Error(`No derived page with route "${route}" in ${JSON.stringify(routes)}`); + return page; +} + +describe('deriveRoutesFromRouterSource', () => { + it('derives the index route from the template default', () => { + const source = ` + import { createRouter, createWebHistory } from 'vue-router'; + import Home from './pages/Home.vue'; + export const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [{ path: '/', component: Home }], + }); + `; + + const routes = deriveRoutesFromRouterSource(source); + expect(routes).toHaveLength(1); + expect(byRoute(routes, '').parentPageId).toBeNull(); + }); + + it('gives every page a single-URL-segment id, never empty — vue-router\'s :pageId param can\'t hold "/" or ""', () => { + const source = ` + export const router = createRouter({ + routes: [ + { path: '/', component: Home }, + { + path: '/clients', + component: Clients, + children: [{ path: ':id', component: ClientDetail }], + }, + ], + }); + `; + + for (const page of deriveRoutesFromRouterSource(source)) { + expect(page.id).not.toBe(''); + expect(page.id).not.toContain('/'); + } + }); + + it('derives flat top-level routes', () => { + const source = ` + export const router = createRouter({ + routes: [ + { path: '/', component: Home }, + { path: '/clients', component: Clients }, + ], + }); + `; + + const routes = deriveRoutesFromRouterSource(source); + expect(routes).toHaveLength(2); + expect(byRoute(routes, '').parentPageId).toBeNull(); + expect(byRoute(routes, 'clients').parentPageId).toBeNull(); + }); + + it('derives nested children, including dynamic segments', () => { + const source = ` + export const router = createRouter({ + routes: [ + { + path: '/clients', + component: Clients, + children: [{ path: ':id', component: ClientDetail }], + }, + ], + }); + `; + + const routes = deriveRoutesFromRouterSource(source); + const clients = byRoute(routes, 'clients'); + const clientDetail = byRoute(routes, ':id'); + expect(clients.parentPageId).toBeNull(); + expect(clientDetail.parentPageId).toBe(clients.id); + }); + + it('nests a flat multi-segment path under its prefix, merging with an existing sibling', () => { + // Two independent top-level entries sharing a path prefix — however + // router.ts declares it (flat siblings here, `children` above), a + // path that extends another one is a sub-page of it, not an unrelated peer. + const source = ` + export const router = createRouter({ + routes: [ + { path: '/success', component: Success }, + { path: '/success/:type', component: SuccessType }, + ], + }); + `; + + const routes = deriveRoutesFromRouterSource(source); + expect(routes).toHaveLength(2); + const success = byRoute(routes, 'success'); + const successType = byRoute(routes, ':type'); + expect(success.parentPageId).toBeNull(); + expect(successType.parentPageId).toBe(success.id); + }); + + it('synthesizes an intermediate page for a multi-segment path with no shorter sibling', () => { + const source = ` + export const router = createRouter({ + routes: [{ path: '/success/:type', component: SuccessType }], + }); + `; + + const routes = deriveRoutesFromRouterSource(source); + expect(routes).toHaveLength(2); + const success = byRoute(routes, 'success'); + const successType = byRoute(routes, ':type'); + expect(success.parentPageId).toBeNull(); + expect(successType.parentPageId).toBe(success.id); + }); + + it('returns an empty list when there is no createRouter call', () => { + expect(deriveRoutesFromRouterSource('export const x = 1;')).toEqual([]); + }); + + it('returns an empty list when routes is not a plain array literal', () => { + const source = ` + const extra = [{ path: '/extra', component: Extra }]; + export const router = createRouter({ + routes: [{ path: '/', component: Home }, ...extra], + }); + `; + + // The spread element isn't an object literal with a `path`, so it's skipped; + // the static entry before it is still picked up. + const routes = deriveRoutesFromRouterSource(source); + expect(routes).toHaveLength(1); + expect(byRoute(routes, '')).toBeDefined(); + }); +}); diff --git a/packages/cli/src/modules/apps/app-version.service.ts b/packages/cli/src/modules/apps/app-version.service.ts index ad2e47708cd..79f8993f3bb 100644 --- a/packages/cli/src/modules/apps/app-version.service.ts +++ b/packages/cli/src/modules/apps/app-version.service.ts @@ -5,7 +5,7 @@ import { generateNanoId } from '@n8n/utils/generate-nano-id'; import { InstanceSettings } from 'n8n-core'; import { UnexpectedError } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; -import { mkdir, rename, rm, stat } from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { extract as extractTar, list as listTar } from 'tar'; @@ -125,12 +125,17 @@ export class AppVersionService { return await this.appVersionRepository.listByAppId(appId); } - /** Source tarball of the active version, or of the newest version when none is active. */ - async readSource(app: App): Promise<{ versionId: string; data: Buffer } | null> { + /** The active version, or the newest version when none is active; `null` when the app has none yet. */ + private async resolveVersion(app: App): Promise { const active = app.activeVersionId ? await this.appVersionRepository.findById(app.activeVersionId) : null; - const version = active ?? (await this.appVersionRepository.listByAppId(app.id))[0]; + return active ?? (await this.appVersionRepository.listByAppId(app.id))[0] ?? null; + } + + /** Source tarball of the active version, or of the newest version when none is active. */ + async readSource(app: App): Promise<{ versionId: string; data: Buffer } | null> { + const version = await this.resolveVersion(app); if (!version) return null; const data = await this.blobStore.readAsBuffer({ @@ -140,6 +145,38 @@ export class AppVersionService { return data ? { versionId: version.id, data } : null; } + /** + * `src/router.ts` of the same version `readSource` would return, or `null` + * when the app has no version, or that version's source has no + * `src/router.ts` (e.g. an app scaffolded with `template: "none"`). + * Extracted to a throwaway directory and discarded once read — unlike + * `distDir`, nothing here is served repeatedly, so there's nothing worth + * caching. + */ + async readRouterSource(app: App): Promise { + const source = await this.readSource(app); + if (!source) return null; + + const tempDir = path.join(this.instanceSettings.n8nFolder, 'apps', `${randomUUID()}-src-tmp`); + await mkdir(tempDir, { recursive: true }); + try { + // Source tarballs hold arbitrary project files, not just the served + // dist output, so extraction only needs the dist filter's path-safety + // checks — its file-type/size limits are dist-specific and don't apply. + await this.extract( + source.data, + tempDir, + createDistTarFilter({ ...DIST_TAR_LIMITS, maxBytes: MAX_UNPACKED_BYTES.source }), + ); + return await readFile(path.join(tempDir, 'src', 'router.ts'), 'utf8'); + } catch (error) { + if (isErrnoCode(error, ['ENOENT'])) return null; + throw error; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + toResponse(version: AppVersion): AppVersionResponse { return { id: version.id, @@ -253,8 +290,12 @@ export class AppVersionService { return entries; } - private async extract(tarball: Buffer, cwd: string): Promise { - const unpack = extractTar({ cwd, strip: 0, filter: createDistTarFilter() }); + private async extract( + tarball: Buffer, + cwd: string, + filter: ReturnType = createDistTarFilter(), + ): Promise { + const unpack = extractTar({ cwd, strip: 0, filter }); await new Promise((resolve, reject) => { unpack.on('error', reject); unpack.on('end', resolve); diff --git a/packages/cli/src/modules/apps/apps.controller.ts b/packages/cli/src/modules/apps/apps.controller.ts index 37b3f9a3ca4..dde80aba638 100644 --- a/packages/cli/src/modules/apps/apps.controller.ts +++ b/packages/cli/src/modules/apps/apps.controller.ts @@ -248,6 +248,17 @@ export class AppsController { return await this.appsService.listPages(appId); } + /** The app's real pages, derived from its source; what the builder UI shows. */ + @Get('/:appId/routes') + @ProjectScope('app:read') + async listRoutes( + _req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('appId') appId: string, + ) { + return await this.appsService.listRoutes(appId); + } + @Patch('/:appId/pages/:pageId') @ProjectScope('app:update') async updatePage( diff --git a/packages/cli/src/modules/apps/apps.service.ts b/packages/cli/src/modules/apps/apps.service.ts index 3f61bf2a05d..25f2eab7fc9 100644 --- a/packages/cli/src/modules/apps/apps.service.ts +++ b/packages/cli/src/modules/apps/apps.service.ts @@ -7,6 +7,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { AppVersionService } from './app-version.service'; import { AppRepository } from './app.repository'; +import { deriveRoutesFromRouterSource } from './derive-routes'; import { AppNotFoundError } from './errors/app-not-found.error'; import { AppQuotaExceededError } from './errors/app-quota-exceeded.error'; import { DataWorkflowNotFoundError } from './errors/data-workflow-not-found.error'; @@ -94,6 +95,17 @@ export class AppsService { return await this.pageRepository.findManyByAppId(appId); } + /** + * The app's real pages, derived from its source's `src/router.ts` rather + * than the (unused) `Page` table — `[]` when the app has no version yet, + * or that version's source has no `src/router.ts`. + */ + async listRoutes(appId: string) { + const app = await this.getApp(appId); + const routerSource = await this.appVersionService.readRouterSource(app); + return routerSource ? deriveRoutesFromRouterSource(routerSource) : []; + } + /** Scoped to `appId` so a pageId from a different app is treated as not found, not just unauthorized. */ async getPage(appId: string, pageId: string) { const page = await this.pageRepository.findOneBy({ id: pageId }); diff --git a/packages/cli/src/modules/apps/derive-routes.ts b/packages/cli/src/modules/apps/derive-routes.ts new file mode 100644 index 00000000000..ed2b60f8a2d --- /dev/null +++ b/packages/cli/src/modules/apps/derive-routes.ts @@ -0,0 +1,146 @@ +import ts from 'typescript'; + +/** One page derived from a router route entry. Shaped like `PageNode` so the same tree logic applies. */ +export type DerivedRoute = { + id: string; + parentPageId: string | null; + route: string; +}; + +const stripSlashes = (path: string) => path.replace(/^\/+|\/+$/g, ''); + +/** "/success/:type" -> ["success", ":type"]; "/" or "" (the index route) -> []. */ +function splitSegments(path: string): string[] { + const stripped = stripSlashes(path); + return stripped === '' ? [] : stripped.split('/'); +} + +/** The index route's stand-in id: base64url of its (empty) path is itself empty. */ +const INDEX_ID = '__index__'; + +/** + * `id` doubles as vue-router's `:pageId` param on the frontend, which can't + * contain '/' — base64url-encoding the node's full path keeps it a single + * URL segment regardless of nesting depth, and is deterministic across + * requests (this is recomputed from source on every read, not stored). + */ +function nodeId(fullSegments: string[]): string { + const path = fullSegments.join('/'); + return path === '' ? INDEX_ID : Buffer.from(path).toString('base64url'); +} + +/** + * Builds the page tree from full URL paths, trie-merged on shared prefixes — + * so "/success" and "/success/:type" end up as parent and child here + * regardless of whether `router.ts` declared the second via `children` or + * as an unrelated flat entry that merely happens to extend the first one's + * path. A prefix with no route of its own (e.g. only "/success/:type" is + * declared, no bare "/success") still gets a synthetic node, since the UI + * needs somewhere to attach the child. + */ +class RouteTree { + private readonly nodes = new Map(); + + add(fullSegments: string[]): void { + this.getOrCreate(fullSegments); + } + + toList(): DerivedRoute[] { + return [...this.nodes.values()]; + } + + private getOrCreate(fullSegments: string[]): DerivedRoute { + const path = fullSegments.join('/'); + const existing = this.nodes.get(path); + if (existing) return existing; + + const parentSegments = fullSegments.slice(0, -1); + const parentPageId = parentSegments.length > 0 ? this.getOrCreate(parentSegments).id : null; + const node: DerivedRoute = { + id: nodeId(fullSegments), + parentPageId, + route: fullSegments[fullSegments.length - 1] ?? '', + }; + this.nodes.set(path, node); + return node; + } +} + +const stringLiteralValue = (node: ts.Expression): string | undefined => + ts.isStringLiteralLike(node) ? node.text : undefined; + +function findProperty(object: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined { + for (const property of object.properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === name + ) { + return property.initializer; + } + } + return undefined; +} + +function walkRoutes( + elements: ts.NodeArray, + parentSegments: string[], + tree: RouteTree, +): void { + for (const element of elements) { + if (!ts.isObjectLiteralExpression(element)) continue; + const pathExpr = findProperty(element, 'path'); + const path = pathExpr && stringLiteralValue(pathExpr); + if (path === undefined) continue; + + const fullSegments = [...parentSegments, ...splitSegments(path)]; + tree.add(fullSegments); + + const childrenExpr = findProperty(element, 'children'); + if (childrenExpr && ts.isArrayLiteralExpression(childrenExpr)) { + walkRoutes(childrenExpr.elements, fullSegments, tree); + } + } +} + +/** + * Parses the `routes` array passed to `createRouter(...)` in an app's + * `src/router.ts` — without executing any code — into the same + * `{ id, parentPageId, route }` tree shape the (unused) DB `Page` model used. + * + * Only understands the literal shape the `app-builder` skill instructs the + * AI to write: a flat array of `{ path, component }` objects, optionally + * nested under `children`. A `router.ts` that isn't in this shape (or isn't + * there at all) yields `[]`, not an error — arbitrary dynamic route + * construction isn't supported, and doesn't need to be. + */ +export function deriveRoutesFromRouterSource(source: string): DerivedRoute[] { + const sourceFile = ts.createSourceFile( + 'router.ts', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const tree = new RouteTree(); + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createRouter' && + node.arguments.length > 0 && + ts.isObjectLiteralExpression(node.arguments[0]) + ) { + const routesExpr = findProperty(node.arguments[0], 'routes'); + if (routesExpr && ts.isArrayLiteralExpression(routesExpr)) { + walkRoutes(routesExpr.elements, [], tree); + } + return; + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return tree.toList(); +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 9871577ed8c..d0b1f39bc24 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5187,25 +5187,16 @@ "apps.pages.empty": "No pages yet", "apps.page.new": "New page", "apps.page.addChild": "Add sub page", + "apps.page.edit": "Edit page", "apps.page.index": "index", - "apps.page.add.input.route.label": "Route", - "apps.page.add.input.route.placeholder": "my-page", - "apps.page.add.input.route.hint": "Leave blank to make this the index page — the page shown when no further path is given, e.g. the app's own home page at the top level. Use :name (e.g. :id) for a dynamic segment, such as /clients/:id.", - "apps.page.add.input.route.subPage.hint": "Use :name (e.g. :id) for a dynamic segment, such as /clients/:id. A sub-page needs a route of its own.", - "apps.page.add.error": "Error creating page", - "apps.page.delete.confirm.title": "Delete page", - "apps.page.delete.confirm.message": "Are you sure you want to delete \"{name}\"? This will also delete any sub-pages. This can't be undone.", - "apps.page.delete.error": "Error deleting page", "apps.page.notFound": "Page not found", "apps.page.getDetails.error": "Error loading page", - "apps.page.content.placeholder": "Page content editor coming soon", - "apps.page.input.route.label": "Route", - "apps.page.input.dataWorkflow.label": "Data workflow", - "apps.page.input.dataWorkflow.placeholder": "Select a workflow", - "apps.page.input.dataWorkflow.hint": "This workflow runs to fetch the page's data. Only workflows that start with an Execute Workflow Trigger show up here.", + "apps.page.content.placeholder": "Ask the AI Assistant to add content to this page", "apps.page.url.label": "Page URL", - "apps.page.save": "Save", - "apps.page.save.error": "Error saving page", + "apps.page.assistant.add.root": "Add a new page to this app.", + "apps.page.assistant.add.child": "Add a new page nested under \"{path}\" in this app.", + "apps.page.assistant.edit": "Update the page at \"{path}\" in this app.", + "apps.page.assistant.delete": "Remove the page at \"{path}\" from this app.", "dataTable.dataTables": "Data tables", "dataTables.noResults": "No data tables found", "dataTable.empty.heading": "Create your first data table", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 39b0519dfe8..d8a5343540d 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -1143,6 +1143,17 @@ function handleAgentPreviewAssistantHandoff(params: AgentPreviewHandoffParams) { } } +/** + * A page +/edit/delete click inside the embedded app builder (this thread is + * already bound to the app, via `InstanceAiAppPreview`'s own target sync) — + * fill the already-open composer instead of opening another thread. + */ +function handleAppPreviewAssistantHandoff(prompt: string) { + stashPendingComposerDraft(props.threadId, prompt); + pendingComposerDraft.value = prompt; + void nextTick(() => chatInputRef.value?.focus()); +} + /** * Reveal the agent artifact, then hand off to the builder to select its Evals * tab and generate. Generation deliberately stays in the builder: it already @@ -1579,6 +1590,7 @@ async function dismissComposerContextChip() { :project-id="preview.activeAppProjectId.value" :version-id="preview.activeAppVersionId.value ?? undefined" :building="preview.activeAppBuilding.value" + @assistant-handoff="handleAppPreviewAssistantHandoff" /> diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiAppPreview.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiAppPreview.vue index 2a5e64b87c4..45f4f69f4d4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiAppPreview.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiAppPreview.vue @@ -17,6 +17,8 @@ const props = defineProps<{ building?: boolean; }>(); +defineEmits<{ 'assistant-handoff': [prompt: string] }>(); + const thread = useThread(); const instanceAiStore = useInstanceAiStore(); const i18n = useI18n(); @@ -67,6 +69,7 @@ watch(() => props.appId, syncAppTarget, { immediate: true }); :project-id="props.projectId" :app-id="props.appId" :artifact-version-id="props.versionId" + @assistant-handoff="$emit('assistant-handoff', $event)" /> diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts index c49e3d9e42d..34b2cb97ae3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts @@ -441,6 +441,7 @@ export function useInstanceAiHandoff() { async function openAppArtifactThread( attachment: InstanceAiAppAttachment, launch: InstanceAiThreadLaunch, + options?: { initialDraft?: string }, ): Promise { if (!instanceAiReady.value) { await routeToSetup(); @@ -472,6 +473,7 @@ export function useInstanceAiHandoff() { } } stashPendingAppAttachment(threadId, attachment); + if (options?.initialDraft) stashPendingComposerDraft(threadId, options.initialDraft); try { const failure = await router.push({ name: INSTANCE_AI_THREAD_VIEW, diff --git a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts index ac06a33d069..5953053ff3a 100644 --- a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts +++ b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts @@ -244,6 +244,54 @@ describe('AppDetailsView', () => { expect(getByTestId('app-theme-editor-stub')).toBeInTheDocument(); }); + it('hands adding a root page off to the assistant with a pre-filled, unsent prompt', async () => { + const { getByTestId } = await renderApp(makeApp()); + + await userEvent.click(getByTestId('app-page-add-root')); + + expect(openAppArtifactThread).toHaveBeenCalledWith( + { type: 'app', appId: 'app-1', projectId: 'proj-1', name: 'Greeter' }, + { source: 'app_builder_page', origin: 'internal', sourceContext: { appId: 'app-1' } }, + { initialDraft: 'Add a new page to this app.' }, + ); + }); + + it("hands a page card's add/edit/delete actions off to the assistant with the page's route", async () => { + appsStore.pages = [{ id: 'p1', parentPageId: null, route: 'clients' }]; + const { getByTestId } = await renderApp(makeApp()); + + await userEvent.click(getByTestId('app-page-add-child')); + expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), { + initialDraft: 'Add a new page nested under "/clients" in this app.', + }); + + await userEvent.click(getByTestId('app-page-edit')); + expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), { + initialDraft: 'Update the page at "/clients" in this app.', + }); + + await userEvent.click(getByTestId('app-page-delete')); + expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), { + initialDraft: 'Remove the page at "/clients" from this app.', + }); + }); + + it("shows a page's direct children indented right beneath it, and acts on their own route", async () => { + appsStore.pages = [ + { id: 'p1', parentPageId: null, route: 'clients' }, + { id: 'p2', parentPageId: 'p1', route: ':id' }, + ]; + const { getAllByTestId } = await renderApp(makeApp()); + + const routes = getAllByTestId('app-page-route').map((el) => el.textContent); + expect(routes).toEqual(['/clients', '/:id']); + + await userEvent.click(getAllByTestId('app-page-edit')[1]); + expect(openAppArtifactThread).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), { + initialDraft: 'Update the page at "/clients/:id" in this app.', + }); + }); + it('prefers the thread build over the stored version and switches to Preview on the first build', async () => { const { getByTestId, queryByTestId, rerender } = await renderApp(makeApp(), { artifactMode: true, diff --git a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue index 032089cf9c4..1461a14fce2 100644 --- a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue +++ b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue @@ -16,16 +16,16 @@ import { useRouter } from 'vue-router'; import CopyInput from '@/app/components/CopyInput.vue'; import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue'; -import { useUIStore } from '@/app/stores/ui.store'; import AppBreadcrumbs from '@/features/apps/AppBreadcrumbs.vue'; import PageCard from '@/features/apps/PageCard.vue'; import AppPreviewFrame from '@/features/apps/components/AppPreviewFrame.vue'; import AppThemeEditor from '@/features/apps/components/AppThemeEditor.vue'; import { useAppsStore } from '@/features/apps/apps.store'; import { useAppDeletion } from '@/features/apps/useAppDeletion'; -import { ADD_PAGE_MODAL_KEY, APP_PAGE_DETAILS, PROJECT_APPS } from '@/features/apps/apps.constants'; +import { useAppPageAssistant } from '@/features/apps/useAppPageAssistant'; +import { APP_PAGE_DETAILS, PROJECT_APPS } from '@/features/apps/apps.constants'; import type { App } from '@/features/apps/apps.types'; -import { getChildCounts } from '@/features/apps/pageTree.utils'; +import { buildPageRows, getChildCounts, getFullRoutePath } from '@/features/apps/pageTree.utils'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability'; import { useInstanceAiHandoff } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff'; @@ -48,12 +48,14 @@ const props = withDefaults( { artifactMode: false, artifactVersionId: undefined }, ); +const emit = defineEmits<{ 'assistant-handoff': [prompt: string] }>(); + const i18n = useI18n(); const toast = useToast(); const router = useRouter(); -const uiStore = useUIStore(); const documentTitle = useDocumentTitle(); -const { confirmAndDeleteApp, confirmAndDeletePage } = useAppDeletion(); +const { confirmAndDeleteApp } = useAppDeletion(); +const { requestPageChange } = useAppPageAssistant(); const instanceAiAvailable = useInstanceAiAvailable(); const { openAppArtifactThread } = useInstanceAiHandoff(); @@ -69,6 +71,10 @@ const previewFrame = useTemplateRef>('previ const rootPages = computed(() => appsStore.pages.filter((page) => page.parentPageId === null)); const childCounts = computed(() => getChildCounts(appsStore.pages)); +// The tree, indented, down to MAX_INLINE_PAGE_LEVELS deep — deeper pages +// still exist, just require opening their nearest shown ancestor to reach. +const pageRows = computed(() => buildPageRows(appsStore.pages, null)); + const appUrl = computed(() => app.value ? `${window.location.origin}/apps/${app.value.namespace}/` : '', ); @@ -118,12 +124,11 @@ const initialize = async () => { } }; -const openAddPageModal = (parentPageId: string | null) => { - uiStore.openModalWithData({ - name: ADD_PAGE_MODAL_KEY, - data: { projectId: props.projectId, appId: props.appId, parentPageId }, - }); -}; +// Embedded inside the app's own Instance AI thread: fill its composer +// directly rather than opening another one — see `handleAppPreviewAssistantHandoff`. +const onEmbeddedDraft = props.artifactMode + ? (prompt: string) => emit('assistant-handoff', prompt) + : undefined; const openPage = async (pageId: string) => { await router.push({ @@ -138,8 +143,45 @@ const onDeleteApp = async () => { if (deleted) await router.push({ name: PROJECT_APPS, params: { projectId: props.projectId } }); }; +const onAddRootPage = async () => { + if (!app.value) return; + await requestPageChange('add-root', app.value, '', onEmbeddedDraft); +}; + +const onAddChildPage = async (parentPageId: string) => { + if (!app.value) return; + const parent = appsStore.pages.find((page) => page.id === parentPageId); + if (!parent) return; + await requestPageChange( + 'add-child', + app.value, + getFullRoutePath(appsStore.pages, parent), + onEmbeddedDraft, + ); +}; + +const onEditPage = async (pageId: string) => { + if (!app.value) return; + const page = appsStore.pages.find((p) => p.id === pageId); + if (!page) return; + await requestPageChange( + 'edit', + app.value, + getFullRoutePath(appsStore.pages, page), + onEmbeddedDraft, + ); +}; + const onDeletePage = async (pageId: string) => { - await confirmAndDeletePage(props.projectId, props.appId, pageId); + if (!app.value) return; + const page = appsStore.pages.find((p) => p.id === pageId); + if (!page) return; + await requestPageChange( + 'delete', + app.value, + getFullRoutePath(appsStore.pages, page), + onEmbeddedDraft, + ); }; const onDeviceChange = (value: unknown) => { @@ -318,11 +360,7 @@ watch(versionId, (next, previous) => {
{{ i18n.baseText('apps.pages') }} - + {{ i18n.baseText('apps.page.new') }}
@@ -333,12 +371,14 @@ watch(versionId, (next, previous) => {
diff --git a/packages/frontend/editor-ui/src/features/apps/PageCard.vue b/packages/frontend/editor-ui/src/features/apps/PageCard.vue index 9cf310b77fc..4c5387acc6a 100644 --- a/packages/frontend/editor-ui/src/features/apps/PageCard.vue +++ b/packages/frontend/editor-ui/src/features/apps/PageCard.vue @@ -8,14 +8,20 @@ import { formatRoutePath } from '@/features/apps/pageTree.utils'; const i18n = useI18n(); -const props = defineProps<{ - page: Page; - childCount: number; -}>(); +const props = withDefaults( + defineProps<{ + page: Page; + childCount: number; + /** How many levels below the top of this list the card sits — 0 for a top-level row. */ + indent?: number; + }>(), + { indent: 0 }, +); defineEmits<{ open: [pageId: string]; addChild: [parentPageId: string]; + edit: [pageId: string]; delete: [pageId: string]; }>(); @@ -25,12 +31,27 @@ defineEmits<{ const routeLabel = computed(() => formatRoutePath(props.page.route, i18n.baseText('apps.page.index')), ); + +// A flex-column parent stretches this card to its own full width, so the +// indent has to come out of that width too, not just shift the box right — +// otherwise the right edge overflows the container. +const cardStyle = computed(() => { + if (!props.indent) return undefined; + const indent = `calc(${props.indent} * var(--spacing--lg))`; + return { marginLeft: indent, width: `calc(100% - ${indent})` }; +});