* feat(ui-library): add the approval-ui block
Presentational components for an approval process: progress, timeline,
route preview, parallel branches, receipts and an action bar with forms.
They take data already shaped and translated for display and depend on
no plugin. Includes a preview demo, its registry example and tests.
* docs(app-skills): list the approval-ui block in the UI Library catalog
* feat(agents): limit runs per coding tool on a runner
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* feat(agent-runner): limit runs per coding tool with --slots
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* feat(agents): edit and show limits per coding tool on the runtimes page
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* chore: format and add the changeset for limits per coding tool
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* fix(agents): make the fields for limits per coding tool optional in shared types
* fix(agents): align queue component with per-tool wait reason
* feat(agents): one tools table per runtime, saved with its settings
---------
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* feat(app-plugin-agents): send custom request headers and a per-conversation session id to model services
A model service can carry request headers of its own (a value may be secret: sealed like the API key and never answered) and name a session header, such as OpenCode Go's x-opencode-session, under which every model call of a conversation sends one stable id. Every request to a provider names the plugin first in its user agent.
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* fix(app-plugin-agents): send OpenCode's session id without a service setting
A model service no longer names a session header. A call to an OpenCode base URL (Zen or Go) carries x-opencode-session by itself, with one session id per conversation and a new one for each call outside any; the id hashes the conversation's subject and thread. The service form drops the session-header field.
* refactor(app-plugin-agents): keep only the OpenCode session header
Plain services no longer send request headers of their own: the headers and headersEncrypted columns, the service API's headers field, the form's header table and the sealing they needed are removed. A call to an OpenCode base URL (Zen or Go) carries x-opencode-session automatically, with one session id per conversation and a new one for each call outside any, and every request names the plugin first in its user agent.
---------
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
Add record lifecycle definitions, transactional transitions, durable effects, recovery, jobs integration, and React bindings. Include ticket, expense, and office flow examples in the Examples application, with concurrency and database compatibility fixes.
Group generated plugin tests by source directory, provide shared Node test configuration, and document the layout. Include TSX tests in Node projects with a regression test for test discovery.
The daily trend chart fixed its Y axis at 56px, which fits token counts but clips cost ticks such as US$20.00 at the chart's left edge. Let recharts measure the formatted labels instead.
Co-authored-by: Project lead <agent+studio-project-lead@studio.noreply>
* fix(projects): keep an invitation's row menu open when the list renders again
The columns depended on the whole useMutation result, a new object each render, so every cell remounted.
* test: open popups the way a user does
A Base UI trigger is wired to its popup only once React runs the passive effects of the render that mounted it, so a bare fireEvent.click right after the trigger appears is dropped under load. Open popups with userEvent, fix the timing races this exposed, and cancel pending replies when the UI Library chat demo resets.
* chore: add a changeset for the new test dependency
* Add the agents, projects, knowledge and releases plugins
Four plugins for agent-run applications, the UI Library's agents and projects items, and the generated UI Library catalog of the application Skill with its check.
* Wait on the sent attachment's link as it renders last in the chat test
* Keep the demo chat's reply timers off window, so they outlive a test's environment quietly
* Update the plugins, templates and UI Library for the new runtime
Device authorization, scoped API keys, service accounts, user invitations and preferences, secrets-backed credentials, the inbox and the cn package across the existing plugins, examples and the three templates, with the UI Library's auth, inbox, authorization and component items.
* Declare the UI Library site's favicon
* Use the NocoBase favicon on the UI Library site
* Fix generated-app shell tests and the Hub's user deletion test
Inline the authentication client in the templates' Vitest config so its guards share the test's react-router, and update the Hub test to the users plugin's new copy and spinner label.
Encrypted secrets, a command manifest derived from the API document, environment-variable manifests, sample data and on-demand Apps in the runtime, plus the new agent protocol, application CLI client, agent runner and Docker App Host packages.
* fix(app-plugin-ai-employee): translate the nocobase-ai Registry item in the plugin namespace
The Registry item translated through its own locale files and a useAITranslate()
hook that picked a language by prefix and fell back to the English default, so a
key it never defined showed English in every language without anything noticing.
It now calls useTranslation('@nocobase/app-plugin-ai-employee') like the file
plugin's Registry item and the plugin's own pages, and its copy moves into the
plugin's client/locales, which the registered plugin loads.
Four keys the item used without defining them get English and Chinese text, and
a test fails when a key the Registry passes to t() is missing from either locale
file or a file translates outside the plugin namespace.
* feat(examples): add an AI employee tasks example and a global AI employee entry
@nocobase/app-plugin-ai-employee-example registers the AI employee iris and the
read-only tool example-ticket-history through AIResourceRegistrar, declares the
/ai-employee-example route with a fallback page, and publishes a tasks-page
Registry item that replaces it: support tickets whose AIEmployeeShortcut offers
an auto-sent analysis and a reply left in the composer, with the selected ticket
as work context, and a button that starts a queue triage through the global chat
controller.
The examples template installs the nocobase-ai item, wraps its signed-in layout
in a global AI employee entry (a floating trigger that opens the shared
conversation as a side panel and expands into a dialog), registers the example
plugin, installs its tasks page and links it from the homepage.
* test(templates): let the Examples layout carry its AI employee entry
The framework alignment check compares the Examples layout with Default byte for
byte, and the global AI employee entry wraps the signed-in shell. Like the
notification button in the header, it is an Examples-owned demonstration: the
check now removes exactly that wrapper, its import and its marker comment,
re-indents what it wraps, and still requires the shell itself to match Default.
* feat(app-plugin-ai-employee): release unused conversation checkpoints on a schedule
A recurring job on the application's jobs service releases the agent
checkpoints of conversations nobody has used for a week, every day at
03:00 UTC by default. Releasing a conversation sets its thread to 0 and
deletes the checkpoints of every thread it has had, in one transaction per
batch, only while the conversation is still unused. A conversation whose
latest message asks for a tool call is never released.
The next run on a released conversation replays its latest stored messages
onto a fresh thread. This applies only to conversations kept in the
plugin's own tables, never to a resume or a tool decision, and never to a
thread-0 conversation that still has a checkpoint there or has no messages,
so existing runs behave as before.
The schedule is configured under ai.checkpointCleanup and validated by
config check.
* feat(app-plugin-ai-employee): default a conversation's thread to 1
A conversation starts on thread 1 when AIConversationsManager creates it,
but the aiConversations.thread column defaulted to 0, so a row inserted
without a thread looked like a conversation whose checkpoints the cleanup
had released. A new migration sets the default to 1.
Thread 0 now always means a released conversation, so the agent decides
whether to replay stored messages from the thread number alone, without
reading the checkpoint store.
* fix(app-plugin-ai-employee): read each cleanup batch's latest messages in two queries
The checkpoint cleaner looked up the latest message of every candidate
conversation with its own query. It now finds each conversation's latest
message id with one groupBy over the batch and reads those messages with one
more query.
The aggregate goes through the Repository rather than the query builder,
because it reads a bigint result back exactly on every dialect, and a
message id is a snowflake beyond the integers a JavaScript number holds.
* refactor(templates): move Playwright tests to tests/playwright/ and drop the local AI e2e test
Keep every test under tests/: Playwright's testDir is tests/playwright/,
Vitest excludes that directory, and tsconfig.node.json typechecks it. The
directory ships with the template like the rest of tests/. The optional
local AI server test is removed; test:e2e still passes with no tests.
The application Skills describe the new location, and the upgrade Skill
says how an existing application moves its e2e/ files.
* refactor(app-template-hub): exclude tests/playwright/ from Vitest like the other templates
The template alignment test requires the three templates to share one
vitest.config.ts. Hub has no Playwright, so the exclusion changes nothing
there today, and a Hub application that adds Playwright already has it.
Replace the single `page:ai.settings` grant with seven settings items under
System management → AI, one per settings page: `ai.employees`,
`ai.llmServices` and `ai.mcpServers` with `read` and `manage`, and
`ai.skills`, `ai.tools`, `ai.usage` and `ai.conversations` with `read`.
Every management route names the permissions it accepts, pages open with
`read` and turn their controls off without `manage`, and previewing another
user's AI file requires `read` on `ai.conversations`.
A migration gives every item and action to each Permission Set that held the
page grant. A grant of every page no longer opens the AI settings.
Remove the management routes no settings page called: employee templates,
create and delete, skill and tool writes, single LLM service and MCP server
reads, and both MCP connection tests. The package moves to the 3.0.0
prerelease line.
* fix(file): time out Office Open XML previews that never finish loading
* fix(file): extend the Office Open XML preview timeout to 3 minutes
Let the first failure settle the preview inside reportViewerError, so it
no longer reads the timeout handle before it is declared and the timeout
callback no longer depends on statement order.
* fix(app-plugin-ai-employee): save a refused message with its stored columns only
A send refused at the parallel conversation limit saved the user message by
spreading it into the insert, so the key the chat sends with it reached
ai_messages, which has no such column, and the request failed with a SQL
error instead of the limit message.
* fix(app-plugin-ai-employee): count a run towards the limit from when it started
The parallel run limit counts conversations whose updatedAt falls within the
last ten minutes, but an update never set updatedAt, so it stayed at the
conversation's creation time: a run in a conversation older than ten minutes
was never counted. Starting a run now sets updatedAt.
* fix(db-mysql): read a string column's literal default back as its value
MySQL and OceanBase report a literal default in information_schema as the bare text, draft rather than 'draft'. The shared literal parser took it for an expression, so a character, enum or temporal column's default had no value once resolved, and a defaulted NOT NULL enum was listed as required in the API document's create schema on MySQL. Both inspectors now quote the bare value of those types, leaving numbers and CURRENT_TIMESTAMP as they are, and a portable contract checks on every dialect that a string and an enum field's default resolves to its value.
* fix(db-mysql): keep MariaDB and expression defaults out of the literal quoting
MariaDB 10.2.7 and later report column_default in the form it was declared,
'draft' for a literal and the bare word NULL for a nullable column without a
default, so quoting it again read 'draft' with its quotes and gave every such
column a "NULL" default. The inspector now reads version() with the columns and
passes a MariaDB default through, mapping its bare NULL to no default.
OceanBase reports default (uuid()) exactly as default 'uuid()', so a character
column's default is now told apart by reading the table's DDL, rather than
quoting an expression into a value the Repository would write.
A temporal default is quoted only when it starts like a date or a time, which
keeps curdate(), now() and CURRENT_TIMESTAMP expressions on every server, and
the temporal type list is taken from mysqlTypes instead of a second copy.
* fix(db-mysql): resolve the backslash escapes in a MariaDB default
MariaDB reports a string default escaped as MySQL writes literals, so
'back\\slash' and a text column's 'it\'s here' read back with the escape left
in them. Their literal now goes through the same unescaping as a MySQL
expression default before the shared parser reads it.
* fix(db-mysql): run the JSON filters on MariaDB and test the dialect against it
MariaDB stores JSON as text and has no cast(… as json), so every JSON equality
and membership filter failed to parse there. Both are now compared through
JSON_OVERLAPS, which MySQL and MariaDB evaluate alike: equality wraps each side
in a one-element array, so object keys compare in any order, array elements in
order, and 1 matches neither "1" nor true.
test:integration now runs the shared suite against MariaDB 10.11 after MySQL,
from a second service in docker-compose.yml, so CI covers both servers the
dialect serves. The isolation test reads tx_isolation where MariaDB before 11.1
has no transaction_isolation.
* fix(db-mysql): compare JSON equality both ways and keep OceanBase view expressions out of defaults
MariaDB compares two arrays inside JSON_OVERLAPS only as far as the second
one goes, so `[1, 2]` equalled `[1]` and every array equalled `[]`. Equality
now checks the overlap in both directions, which both servers evaluate
alike, and the JSON filter contract covers empty and partial containers.
The JSON filters need MySQL 8.0.17, as membership already did; the README
and changeset say so.
OceanBase reports `default (uuid())` and `default 'uuid()'` alike on a view
too, and a view's DDL declares no defaults, so a character default on a
view that reads like a function call is now read as an expression.
The MySQL integration runner no longer starts MariaDB after an interrupted
MySQL run, or while the MySQL database is kept.
* fix: answer a malformed task id with 403 on PostgreSQL, and keep the articles test portable to OceanBase
The notification example compared any path id with its uuid column, which PostgreSQL rejects with a 500 for a value like 'missing'; an id that is not a UUID now names no task. The examples template's articles migration test gives content in its raw insert, because OceanBase keeps no TEXT default in the table.
* fix(app-plugin-notification-example): match a task id in any case, and cover a malformed id on update
Validate the id with z.guid() instead of a hand-written pattern and lowercase it
before the query, so an uppercase id names the same task on SQLite and MySQL as
on dialects that compare uuid natively. Name Kingbase and MSSQL alongside
PostgreSQL as the dialects that reject a malformed uuid, and test PATCH with a
malformed id and GET with an uppercase one.
* docs: point page tests at app-testing/client, and test the Skill's page example with real translations
AGENTS.md and the AGENTS.md create-plugin generates name @nocobase/app-testing/client for page tests. The plugin-development Skill's page test example renders with the strict runtime from @nocobase/i18n/testing and the plugin's locale file instead of a mocked t, which let a missing or misspelt key pass. The example keeps its mocked services until @nocobase/app-testing is published.
* feat(create-plugin): prepare generated client plugins for page tests, and render the Skill's page test with renderWithApp
The Skill's settings page test renders inside a client application with
renderWithApp(), registers the real AuthorizationClient through services and
answers its permissions request with answerApi(), and shows the locale keys
the page needs, so it runs as written. A plugin create-plugin generates with
client code gets app-testing and the jsdom test packages, and a vitest.config.ts
that runs tests/client/ under jsdom; its AGENTS.md describes page tests in a
section of their own, including the plugins option. AGENTS.md no longer calls
renderWithApp()'s toaster the real one.
* fix(app-plugin-file): embed PDF previews as application/pdf and cover PDF in the Skill checklist
Refs TestManage problem #234.
* fix(app-plugin-file-example): embed PDF previews as application/pdf
Apply the Registry preview's PDF rule to the file example's preview
dialog: refuse a response carrying an active markup type and retype the
blob to application/pdf before embedding it. Clarify that retyping is the
safeguard, assert the frame source in the Registry test, and drop the
canvas-renderer history from the file Skill.
* refactor(app-plugin-file): share the PDF preview checks and tidy their tests
Use isActiveMarkupMimeType in the file example's previewKind instead of a
second inline list, name the Registry PDF preview error once, make the
example's discarded refusal message describe what happened, and type the
createObjectURL mock instead of asserting its argument.
* docs(app-plugin-file): separate browser and session failures in the PDF check
A login redirect during a PDF preview points at the session, not the
browser. Drop the example's unread refusal message and state the fixed
case precisely in the changeset.
* docs(app-plugin-file): state the PDF preview risk as external content URLs
* refactor(app-plugin-file): name the refused PDF response and tidy the MIME helpers
Report an HTML or XML response to the PDF preview with its own message so it
is distinguishable from a failed request, share MIME normalization in
file-preview.ts, move the example's active markup check into lib/mime.ts and
shorten the Skill's PDF guidance.
* docs(app-plugin-file): scope the PDF browser-limitation note to headless browsers
---------
Co-authored-by: gchust <5254737+gchust@users.noreply.github.com>
* fix(file): fall back to download when a previewed image fails to load
* fix(file): show the type icon in the image fallback instead of reloading the failed image
* fix(file): ship the image preview fallback in the Default template
Refresh the Default template's copy of the File Registry, which the
default-file-registry check requires, and add the template to the
changeset. Give ImagePreview a required url so a missing URL can no
longer match the initial failed-URL state, and replace the empty-URL
sentinel in DownloadFallback with an explicit FileThumbnail iconOnly prop.
The shell re-renders from the shared sidebar preference only once its subscription effect has run, so the test now waits for the collapsed and expanded states instead of asserting right after the click. Applied to all three templates.
Moves app-server, app-client, authentication, users, hub, workflow and ai-employee to 2.0.0-beta so their next release is 2.0.0-beta.0, republishes their 32 internal dependents, and makes validate-changesets reject a major changeset on an X.0.0-beta.N package while in prerelease mode.
Generates an OpenAPI 3.1 document for every application at GET /api/swagger and serves Swagger UI at GET /api/swagger/docs to signed-in users and API keys. Every hand-written /api route is declared or explicitly hidden, data endpoints are documented per field, Better Auth's endpoints are merged, routes behind runtime dispatchers are registered generically, and pnpm openapi:check enforces the declarations in CI. All changed packages carry changesets.
Step 9 of the test infrastructure plan: a page test renders inside a real client application instead of mocking `@nocobase/app-client`.
## What changes
**`@nocobase/app-client` takes a `fetch` and mounts its providers under any router.** `new ClientApplication({ runtime, createRenderConfig, fetch })` sends the API client's requests through `fetch` instead of the global one; production leaves it out. `AppClientProviders` is the part of `AppClientRoot` inside its `BrowserRouter` — the application context, the React providers and Refine — around its children, so a test can render a page in a started application under a `MemoryRouter`. `AppClientRoot` renders the same tree as before.
**`@nocobase/app-testing/client`.** `renderWithApp(ui, { plugins, namespace, route, server | fetch })` starts a client application with the plugins' services and strict translations and renders the page in it, so `useApiClient()`, `useService()`, `useToaster()` and `useTranslation()` are the real ones:
- `server` is an application from `createTestApp()`, reached in process under its base path, with `cookie` from `signIn()` to act as a user; without it, `fetch` answers each request, and a request nothing answers fails the call that sent it.
- `answerApi(handler)` builds that `fetch` from a function of `{ method, path, query, json }` returning the body, so a `vi.fn()` handler replaces a mocked `request` and still records every call.
- `services` registers stand-ins before the plugins start; toasts render after the page as plain text and `toasts()` lists them; the application shuts down when the test finishes.
- The entry loads nothing from `@nocobase/db-testing`, so it runs under jsdom. The client packages are optional peers.
**`@nocobase/dev-config`'s React preset inlines `@refinedev/react-router`.** Loaded by Node, Refine's router bindings got a different copy of `react-router` than the test, so an application rendered under a test's `MemoryRouter` failed with "useLocation() may be used only in the context of a <Router> component". Nothing hit it before because no test rendered Refine's default router bindings under a router of its own.
**Pilot.** `@nocobase/app-plugin-hub`'s `api-keys-page.test.tsx` drops its `vi.mock('@nocobase/app-client')`, the stand-in toaster and the i18n wrapper, and answers the Hub API through `answerApi()`; its ten tests keep their assertions. The rest of the 19 plugin and example packages and 3 templates that mock `app-client` move in later changes.
## Verification
- `@nocobase/app-client`: lint, typecheck and its 161 tests, including the injected `fetch` and `AppClientProviders` under a `MemoryRouter`.
- `@nocobase/app-testing`: lint, typecheck, build, its node and client test projects, and `pnpm pack` with publint and attw; the built `./client` imports nothing from `@nocobase/db-testing`.
- `@nocobase/dev-config`: `pnpm --filter @nocobase/dev-config check`, and the tests of all 27 packages on the React preset.
- `@nocobase/app-plugin-hub`: lint, typecheck and its full suite (424 tests).
Changesets: `app-client-providers-and-fetch.md`, `dev-config-inline-refine-router.md` and `app-testing-client.md`.
* fix(db-mysql): write a text field's default into the table
Knex drops every default on a MySQL TEXT column, so a row inserted below the Repository failed on a NOT NULL text column. The dialect spells the default into the column type in the expression form MySQL takes, and a portable contract checks on every dialect that a raw insert gets a created or added text field's default.
* test: compare decimals by value where server databases pad them
PostgreSQL and MySQL return a decimal padded to its column's or its aggregate's scale. The repository routes and numeric examples tests strip that padding before comparing, so they pass on every dialect.
* fix(db-mysql): give a text default the expression form instead of a second literal
Spelled into the column type, the default was compiled twice: Knex does not treat a type it did not build as TEXT, so it appended the literal default MySQL rejects. A dialect now returns a column's default as an expression through schema.columnDefault, and MySQL names the type alone.
* fix(db-oceanbase): write a text field's default into the table, as MySQL does
OceanBase compiles DDL with the same Knex MySQL compiler, so the text defaults contract failed there the same way. It also compares the grouped average in the repository routes test by value.
* fix(db-oceanbase): keep text defaults out of the table, which OceanBase rejects in every form
OceanBase refused the expression default MySQL takes on a TEXT column, so a text field with a default would no longer create. The dialect goes back to leaving the default to the Repository, its README says so, and its integration profile declares schema.textDefaults: 'unsupported' so the shared contract skips it.
* fix(db-mysql): cover the alter and inspector paths of text defaults, share the decimal test helper
- Add an integration case that alters a text field to gain a default and a unit case for the alterColumn path, and read a quoted default back through the inspector.
- Move withoutDecimalPadding into @nocobase/db-testing so the app-server and examples tests share one copy.
- Document the MySQL 8.0.13 minimum the expression default needs, in the README and the changeset.
- Correct the columnDefault doc comment: the drop is MySQL's rule for text and blob columns, not a general Knex one.
* fix(db-mysql): read a text default back without the escapes MySQL reports it with
information_schema reports an expression default escaped twice: as the string literal MySQL stores, with backslash escapes, and again as its own text. Only the outer layer was undone, so a default with a quote came back as it\'s here. The inspector now resolves the literal's escapes as well.
* test(db-testkit): skip redefining a text column on Oracle, which refuses to modify a CLOB
Knex compiles a column alteration as MODIFY with the type restated, and Oracle rejects that for a CLOB with ORA-22859. The Oracle profile declares schema.textAlterations: 'unsupported', so the case that gives an existing text field a default skips it until the dialect alters a CLOB without its type.
* refactor(templates): build the sidebars from shadcn Sidebar primitives
Replace the hand-rolled LayoutSidebar and NavigationTree in the default,
examples and hub templates with shadcn's Sidebar, keeping the collapsed
menu behaviour from #317: zero-delay tooltips, hover and keyboard-focus
popovers with permission-filtered children, nested expansion, parent
navigation and the selected group's highlight.
components/ui/sidebar.tsx stays as the shadcn CLI writes it. The layouts
customize it from outside: widths through the provider style in spacing
units, a phone Sheet with a translated title rendered by app-sidebar.tsx,
and a document keydown listener that keeps Ctrl/Cmd+B from reaching the
provider's window listener.
Claude-Session: https://claude.ai/code/session_01KLvKwu4oUSHnFgGAy3M4v2
* fix(templates): tidy the sidebar footer and add the edge rail
Space the footer like the menu above it: the shield aligned to the first
line, the slogan's two lines, then the name and version on their own
truncated lines in the body font. In icon mode only the shield remains,
with a tooltip carrying the same text.
Add shadcn's SidebarRail with a translated label and title, passed as
props that override the primitive's built-in "Toggle Sidebar".
Claude-Session: https://claude.ai/code/session_01KLvKwu4oUSHnFgGAy3M4v2
* refactor(ui-library): split auth-ui into presentational auth components
Replace the auth-ui block with four presentational registry components built
from shadcn primitives: auth-forms (Field, InputGroup, Alert), auth-methods
(Tabs, FieldSeparator, Button), auth-centered-layout and auth-split-layout.
They call no plugin and take every string as a prop with an English default.
The templates' sign-in, sign-up and password pages become application code
that wires the forms to the authentication plugin's headless actions,
translates them from client/locales, and keeps the NocoBase brand panel as
the split layout's aside. The ui-library preview loads shadcn/tailwind.css
and the shadcn Label so the data-attribute variants render.
Claude-Session: https://claude.ai/code/session_01KLvKwu4oUSHnFgGAy3M4v2
* fix(ui-library): keep auth demo links inside their demo
Claude-Session: https://claude.ai/code/session_01KLvKwu4oUSHnFgGAy3M4v2
* refactor(templates): install the auth registry blocks under client/extensions
Claude-Session: https://claude.ai/code/session_01KLvKwu4oUSHnFgGAy3M4v2