## Context
Since #19890 (Translate standard page layouts), the server's
`PageLayoutTab.title` resolver translates standard tab titles at query
time. A workspace member in French viewing a `messageChannel` (or any
system object with a standard page layout) receives `tab.title =
"Accueil"` / `"Chronologie"` instead of `"Home"` / `"Timeline"`.
The `SYSTEM_OBJECT_TABS` guard in `PageLayoutTabsRenderer` was comparing
against an English-only literal allow-list, so every tab was dropped,
`sortedTabs` became empty, and `<PageLayoutMainContent />` never mounted
— the record page rendered blank (no fields, timeline, email thread,
etc.).
## Fix
Only run the allow-list filter when the resolved layout is the synthetic
`DEFAULT_RECORD_PAGE_LAYOUT` (the client-side fallback for the few
system objects with no server-side standard page layout config, e.g.
`workspaceMember`, `attachment`, `message`). That layout ships hardcoded
English tabs, so the English allow-list still works in every locale.
System objects that do have a server-side standard page layout
(`messageChannel`, `connectedAccount`, `workflowRun`, …) are no longer
filtered at all, the server only ever persists Home/Timeline/Flow tabs
for them, so no filter is needed.
## Before
<img width="1262" height="722" alt="Screenshot 2026-05-04 at 15 15 54"
src="https://github.com/user-attachments/assets/348c9e9d-0ae1-4046-8ead-470ed8263cb5"
/>
## After
<img width="1281" height="658" alt="Screenshot 2026-05-04 at 15 15 36"
src="https://github.com/user-attachments/assets/7a9953b1-7320-4f1a-8c02-1688d3eda3ae"
/>
## Note
Next step should be to backfill those system objects with real record
page layouts so we can remove this filter logic
## Summary
Fixes `METADATA_VALIDATION_FAILED` / “view field already exists” when
saving dashboard **record table** widgets after the first persist (e.g.
several table widgets on one dashboard).
## Problem
Draft view columns used **client-generated** `viewField` ids. After
save, the API stored **different** ids. The next upsert still sent the
old draft ids as `viewFieldId`. The server only matched on that id,
missed every row, and tried to **create** columns that already existed
for the same `fieldMetadataId` + view.
## Summary
App developers can now declare third-party OAuth integrations (GitHub,
Linear, Slack, etc.) in their manifest and the platform handles the full
authorize → callback → token-exchange → refresh → injection lifecycle.
The dev writes ~10 lines of config and reads tokens via
`useOAuth('linear')` inside any logic function.
```ts
// app/src/oauth-providers/linear.ts
export default defineOAuthProvider({
universalIdentifier: '...',
name: 'linear',
displayName: 'Linear',
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
connectionMode: 'per-user',
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
});
// app/src/logic-functions/handlers/...
const { accessToken } = useOAuth('linear'); // throws OAuthNotConnectedError if missing
```
## Architecture
- **Storage**: extends the existing `connectedAccount` table — new
nullable `applicationOAuthProviderId` FK + new `app` value on the
`ConnectedAccountProvider` enum. Existing Google/Microsoft flows are
untouched.
- **OAuth flow**: a single `/apps/oauth/authorize` +
`/apps/oauth/callback` controller pair handles every app provider. State
travels in a JWT signed via the existing `JwtWrapperService` (new
`APP_OAUTH_STATE` token type).
- **Token exchange**: goes through
`SecureHttpClientService.createSsrfSafeFetch()` (so an installed app
can't point `tokenEndpoint` at internal hosts).
- **Refresh**: piggybacks on the existing
`ConnectedAccountRefreshTokensService` dispatch — Google/Microsoft
drivers untouched, new app driver lives engine-side under
`application-oauth-provider/refresh/`.
- **Injection**: the executor injects refreshed tokens as env vars
(`OAUTH_<NAME>_ACCESS_TOKEN`, `_HANDLE`, `_SCOPES`, `_CONNECTED`); the
SDK helpers `useOAuth` / `useOptionalOAuth` read them.
- **Frontend**: auto-rendered "OAuth Connections" section under each
app's settings tab (no custom front component needed). App-managed
connections are filtered out of `/settings/accounts` so the
email/calendar page stays focused.
- **Disconnect**: best-effort revoke against the manifest's
`revokeEndpoint` before deleting the row.
## Reference app
`packages/twenty-apps/internal/twenty-linear/` exercises the full
pipeline:
- `defineOAuthProvider` for Linear
- `POST /linear/create-issue` and `GET /linear/teams` HTTP-route logic
functions
- Vitest tests for the handlers
## Tests
- 14 server-side Jest tests: token-exchange util (form-urlencoded vs
JSON, PKCE, error paths), flow service (authorize URL shape, state
binding, ConnectedAccount upsert on first/reconnect, per-workspace mode,
invalid state)
- 8 app-level Vitest tests: handler error paths, GraphQL request shape,
Linear error propagation
- All 4 packages clean: `npx nx lint:diff-with-main` and `npx tsc
--noEmit`
## Test plan
- [ ] Apply migration on a dev DB: `npx nx run
twenty-server:database:migrate:prod`
- [ ] Regenerate frontend types: `npx nx run
twenty-front:graphql:generate --configuration=metadata`
- [ ] Create a Linear OAuth app at
https://linear.app/settings/api/applications/new with redirect URI
`<SERVER_URL>/apps/oauth/callback`
- [ ] Deploy + install `twenty-linear` on a workspace, paste the Linear
client id/secret into the app's variables
- [ ] Click "Connect Linear" in the app's settings tab → complete OAuth
→ verify `connectedAccount` row created with `provider = 'app'`
- [ ] Trigger `POST /linear/create-issue` with a valid teamId → verify
issue lands in Linear
- [ ] Disconnect → verify the row is deleted and (if Linear's revoke
endpoint is configured in the manifest) the revoke call fires
- [ ] Verify `/settings/accounts` does NOT show the Linear connection —
it appears only under the Linear app's settings tab
## Out of scope (deliberately)
- **Cron + per-user providers**: a cron-triggered function with a
per-user OAuth provider currently returns `CONNECTED=false` (no user
context). The follow-up design is `useOAuthForUser(name,
userWorkspaceId)` paired with a `POST /apps/oauth/connection-token`
endpoint, deferred to keep this PR focused.
- **Token encryption at rest**: tokens stored as plain `varchar`
matching the existing Google/Microsoft pattern. Worth a separate
cross-cutting PR.
- **Manifest endpoint pinning**: a malicious app upgrade could change
`tokenEndpoint` silently. Same trust model as logic-function source code
(which already runs arbitrary server-side); worth tightening across the
whole upgrade pipeline rather than just OAuth.
- **CLI helpers** (`twenty oauth show-callback-url`, `twenty oauth
connect`): manual setup for v1.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## PR Description
### Summary
- Add AI chat thread actions: rename, archive (soft-delete via
`deletedAt`), and hard-delete with confirmation.
- Add chat thread filtering by status (active/archived/all), group-by
mode, and last activity.
- Rework drawer/side-panel thread lists to share thread sections, item
menus, archive icons, and empty-state behavior.
- Extend server chat thread model/API with `deletedAt`, mutations,
broadcasts, and archive-aware stream guards.
### Decisions
- Two-stage lifecycle: Archive sets `deletedAt` (soft); Delete is a
separate action on archived threads that hard-deletes the row. Aligns
with Twenty's soft-delete convention (Felix's suggestion).
- `lastMessageAt` is derived from `MAX(agentMessage.createdAt)` on read,
not stored. List query does inline aggregation for sort; `@ResolveField`
covers single-thread / mutation paths so the schema contract is honest
everywhere. Matches `timeline-messaging.service.ts` precedent and the
existing `totalInputCredits` / `totalOutputCredits` `@ResolveField`
pattern in the same resolver.
- Replaced auto-CRUD `chatThreads` (cursor-paginated Connection) with a
custom `[AgentChatThreadDTO!]` resolver. Frontend metadata-store treats
threads as a flat collection and filters/sorts client-side, so cursor
pagination was performative.
- Sending in an archived chat unarchives it optimistically on the client
and authoritatively on the server.
- Grouping and last-activity filtering use `lastMessageAt ?? updatedAt`
so archive/rename don't bump threads in the list.
- Kept metadata-store core API unchanged; AI chat uses the same local
cast pattern already used by other metadata-store partial updates.
https://github.com/user-attachments/assets/1b179b7b-1a2a-4a7a-aa0a-c88f6f051a87
## Summary
Removes the `?token=` URL query-parameter fallback from JWT
authentication. Every authenticated route (`/graphql`, `/metadata`,
REST, etc.) used to accept a full workspace JWT in the URL alongside the
`Authorization` header. The fallback was intended for the REST API
Playground only, but it was wired into the global Passport JWT extractor
and applied to every route.
URL-borne tokens leak into:
- Server access logs (nginx / Apache / CDN / proxy / load balancer)
- Log aggregators (Datadog, CloudWatch, Loki, Sumo, …)
- Browser history (and synced across devices)
- `Referer` headers when navigating to external pages
- Browser extensions with `tabs`/`webNavigation` permissions
A leaked log line was equivalent to a leaked workspace credential for
the lifetime of the token.
## What changed
- **`jwt-wrapper.service.ts`** — `extractJwtFromRequest()` is now
header-only (`ExtractJwt.fromAuthHeaderAsBearerToken()`). No URL
fallback anywhere in the system.
- **`open-api.service.ts` / `base-schema.utils.ts`** — Dropped the
`token?: string` plumbing that propagated the URL token into the schema
description. The "Authentication" section gains a "Never put your token
in a URL" warning. The "Usage with LLMs" section is rewritten to point
at the **Twenty MCP server** (header-authenticated, exposes typed tools
— the right tool for AI agents) instead of telling users to paste
tokenized OpenAPI URLs into Cursor/ChatGPT.
- **`RestPlayground.tsx`** — Now fetches the OpenAPI schema with
`Authorization: Bearer ${playgroundApiKey}` and passes the JSON document
to Scalar via `spec.content` instead of constructing a URL with
`?token=`. Aborts in-flight fetches on unmount/key change.
- **New integration test** — Asserts `?token=` is rejected on `/rest/*`,
`/graphql`, `/metadata`, and that `/rest/open-api/core?token=` returns
the unauthenticated base schema (no workspace object paths).
## Why not keep `?token=` scoped to the OpenAPI endpoint only
The first instinct was to narrow the fallback to just
`/rest/open-api/*`, since that endpoint is what the Scalar playground
component fetches. But the same log-leakage attack still applies to that
endpoint — the workspace JWT would still sit in access logs, just from
one URL pattern instead of all of them. The cleaner long-term fix is to
remove the URL pattern entirely and let the playground fetch with a
header (Scalar supports `spec.content` natively). For LLM agent use, the
MCP server is a strictly better path — typed tools, OAuth or
header-based API key auth, no tokens in URLs anywhere.
## Not affected
File downloads at `file-url.service.ts` also use `?token=` URLs but with
separate, short-lived `FILE`-typed tokens validated by
`file-by-id.guard.ts` directly (not via `extractJwtFromRequest`). That
mechanism is scoped per-file with limited TTL and is acceptable.
## Action required for users
Anyone who previously pasted `?token=` URLs into LLM tools, scripts,
bookmarks, or shared configs should rotate their workspace API keys.
Those tokens are likely captured in server logs / chat histories
somewhere.
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx typecheck twenty-front` — clean
- [x] `npx nx lint:diff-with-main twenty-server` — clean
- [x] `npx nx lint:diff-with-main twenty-front` — clean
- [x] OpenAPI utils unit tests + snapshots — 11/11 pass
- [ ] Run the new integration test against a live server: `nx run
twenty-server:test:integration:with-db-reset` and verify
`url-token-auth-rejection.integration-spec.ts` passes
- [ ] Manually open Settings → Playground → REST, confirm the schema
loads (now via Bearer header instead of `?token=` URL)
- [ ] Manually verify `POST /metadata?token=<jwt>` (no Authorization
header) returns Forbidden, and the same request with the token in the
header returns the user
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
https://github.com/user-attachments/assets/fe1461c7-0d5c-4c6f-8c2e-2cf569e7de90
## What
Fix `RECORD_SELECTION` items leaking into the command menu when nothing
is selected, and unify how the menu renders in normal vs edit mode.
## The bug
`RECORD_SELECTION`-availability items were showing up even when
`numberOfSelectedRecords === 0`. New util
`doesCommandMenuItemMatchSelectionState` gates them, applied
consistently in the runtime provider and the editor.
## The refactor
`PinnedCommandMenuItemButtonsEditMode` was a 140-line near-duplicate of
`PinnedCommandMenuItemButtons` with its own (drifting) filter logic.
Killed it. Edit mode now flows through the same
`CommandMenuContextProvider` with a new `isInPreviewMode` flag — one
filter chain, one rendering path.
## Behavior in edit mode
**Header (pinned buttons in page header):**
- Runs the full filter chain — object metadata, page type, selection
state, page layout, *and the conditional availability expression*
- Buttons render at full styling but are inert via `pointer-events:
none` + `cursor: not-allowed`
- Preview now reflects exactly what users will see on the live page (not
a grayed-out approximation)
**Side panel editor:**
- New `useEditableCommandMenuItems` hook
- Same filters as runtime *minus* the conditional availability
expression and `FALLBACK` items — so it surfaces everything that's
actually configurable for this page context
- Still gates on selection state — if no records selected,
`RECORD_SELECTION` items are hidden from the editor too. Open to
feedback if we'd rather always show them so users can pin them ahead of
time.
## Misc
- `usePinnedCommandMenuItemsInlineLayout` — visible count now waits
until every item is measured before committing. Fixes a flash of wrong
counts on mount/resize
- Renamed `useCommandMenuContextApi` → `useCurrentCommandMenuContextApi`
— name now conveys it reads from the *current* scoped context store
- Copy: "Records selected" → "Record(s) selected"
## Summary
Adds `h1`–`h6` component overrides to `LazyMarkdownRenderer` so that
`[[record:...]]` references placed inside markdown headings in the AI
chat are parsed by `processChildrenForRecordLinks` and rendered as
clickable `RecordLink` chips, matching the behavior already in place for
`p`, `li`, `td`, `th`, and `a`.
Fixes#20072
## Test plan
- [ ] In AI chat, ask a question whose answer places a record reference
inside a markdown heading (e.g. `## Found [[person:uuid:John Doe]]`) and
confirm a clickable `RecordLink` chip renders instead of the raw
`[[...]]` text.
- [ ] Verify heading styling (sizes, weights, margins) is unchanged.
Generated with [Claude Code](https://claude.ai/code)
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nitin <ehconitin@users.noreply.github.com>
## Summary
A customer reported that after **Stop Impersonating**, the sidebar still
showed the impersonated user's pinned favorites, the AI chat tab toggle,
and the AI chat history — even though the original admin's session was
correctly restored.
## Root cause
The refactor in #19597 replaced the previous `signOut()`-based stop flow
with an in-place token swap, but only cleared Apollo cache + reloaded
the user. Several user-scoped client stores were left untouched:
- **`metadataStoreState`** is localStorage-backed
(`navigationMenuItems`,
`agentChatThreads`, `views`, `pageLayouts`, etc.) and only refreshed
by `MinimalMetadataLoadEffect`. That effect is gated by
`metadataLoadedVersion` + `desiredLoadState`, neither of which flips
on a same-workspace token swap, so the effect never re-runs.
- **In-memory AI atoms** (`currentAiChatThreadState`,
`agentChatInputState`,
`hasInitializedAgentChatThreadsState`) keep pointing at the
impersonated user's selected thread / input.
- **Session localStorage keys** (`agentChatDraftsByThreadIdState`,
`lastVisitedObjectMetadataItemIdState`,
`lastVisitedViewPerObjectMetadataItemState`,
`playgroundApiKeyState`) carry the impersonated user's drafts and
navigation state.
`clearSession()` (used by logout) avoids this because it calls
`applyMockedMetadata()` and flips `desiredLoadState` mocked↔real, which
chain-triggers a full metadata reload on next sign-in.
## Fix
Extract a `resetUserScopedClientState` helper inside
`useImpersonationSession` that:
1. Calls `clearSessionLocalStorageKeys()` to drop user-scoped
localStorage
keys.
2. Resets the in-memory AI session atoms.
3. Marks `metadataStoreState['agentChatThreads']` as `'empty'`.
`useLoadStaleMetadataEntities` does **not** handle this entity key, so
without an explicit reset to `'empty'` the
`AgentChatThreadInitializationEffect` (which only fires on `'empty'`)
would never refetch.
4. Calls `invalidateMetadataStore()` to clear all
`currentCollectionHash`
values and bump `metadataLoadedVersion`, forcing
`MinimalMetadataLoadEffect` to re-run and refetch
`navigationMenuItems`, `views`, `pageLayouts`, etc. against the new
token.
The helper is applied to both `startImpersonating` and
`stopImpersonating`
— start had the same latent bug; the impersonated user could see the
admin's favorites until the cache happened to refresh.
## Test plan
- [ ] As an admin user, pin some favorites in the sidebar
- [ ] Impersonate a user with different favorites → favorites should
switch to the impersonated user's
- [ ] Click "Stop Impersonating" → sidebar should immediately show the
admin's favorites (not the impersonated user's)
- [ ] As an admin **without** AI permission, impersonate a user **with**
AI permission, open AI chat, send a message, then stop impersonating
→ AI chat history should be empty / inaccessible (the AI tab
visibility itself is fixed in a separate PR)
- [ ] Type a draft in AI chat as the impersonated user → after stop, the
draft should be gone
- [ ] Verify regular sign-out still works while impersonating
- [ ] Verify the impersonation banner still shows / hides correctly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
## Summary
Iterative redesign of two related areas in settings, plus a new
`pages/settings/layout/` folder for read-only entity detail pages.
### Application content tab
- **Grouped into three sections** — Data / Layout / Logic — each with
one H2 + multiple `TableSection`-wrapped sub-tables (mirrors the
role-permissions pattern). Replaces six per-category table/row
components with one uniform `<SettingsApplicationContentSubtable>` +
`ApplicationContentRow` shape (net **−~700 lines** across the refactor).
- **All 10 row categories now clickable** for installed apps:
- Objects / Fields / Logic functions / Front components → existing
detail pages
- Agents → existing `AiAgentDetail`
- Skills → existing `AiSkillDetail` (looked up by `Skill.applicationId +
name`)
- Roles → existing `RoleDetail` (looked up by
`Role.universalIdentifier`)
- Views / Page layouts / Navigation menu items → **new** detail pages
(see below)
- **Lifecycle hooks visible** — `pre-install` / `post-install` logic
functions are surfaced in the Trigger column instead of appearing as
empty/misconfigured.
### Logic function settings (Triggers + Test tabs)
- Triggers tab is now editable (HTTP / Cron / Database event / AI tool)
with a `<SettingsLogicFunctionTriggerSection>` wrapper that owns the
toggle, header, and read-only short-circuit.
- HTTP section gets a Live URL field with copy-to-clipboard.
- Each section shows a **Sample input** preview (the JSON the function
will receive) using the same payload builders the Test tab uses.
- Test tab: **Simulate trigger** buttons that prefill the JSON input
from the configured trigger's schema. Replaces an unclickable `<Select>`
(which auto-disables when there's only one option — the typical case).
- Read-only behavior for installed-app functions: explicit `<Callout>`
notice when there's no trigger; trigger sections render as disabled
controls when there is one.
- Removed the empty Environment Variables section from the Settings tab
(it just told the user to go elsewhere).
### New `pages/settings/layout/` folder
Three new app-scoped detail pages so users can drill into entities the
GraphQL `Application` type doesn't expose by id (keyed by manifest
`universalIdentifier`):
- `ApplicationViewDetail` — type, object, visibility + Fields / Filters
/ Sorts subsections (field UIDs resolved to readable labels via
`useFieldLabelByUid`)
- `ApplicationPageLayoutDetail` — type, object + per-tab subsections
listing widgets
- `ApplicationNavigationMenuItemDetail` — type, destination (resolved),
icon, color, position
Each page reads from the marketplace manifest the parent app page
already loads (no extra queries). Folder set up so a future "Layout"
settings tab can grow here (analogous to the existing `data-model/`
folder under the Data tab).
### Other consistency fixes
- Breadcrumbs on every app-scoped entity detail page now include a
category crumb so users know what they're looking at: `Workspace /
Applications / Timely / Navigation menu items / Time entry`.
- Title fallback for nav menu items uses the resolved destination
(`"Time entry"`) instead of the raw enum (`"OBJECT"`).
- New shared utils: `getNavigationMenuItemDestination`,
`resolveManifestObjectLabel`, `getLogicFunctionTriggerLabel`,
`<MonoText>`.
## Backend changes
Only one minor schema-shape change (additive): added `applicationId` to
the `SkillFields` GraphQL fragment and `universalIdentifier` to the
`RoleFragment` so the new lookups have what they need. Generated
metadata schema patched in-tree to match — regenerate with `nx run
twenty-front:graphql:generate --configuration=metadata` if it drifts.
## Test plan
- [ ] Application content tab on an installed app shows the 3 grouped
sections; rows in each section are clickable
- [ ] Click an Object → existing object detail page
- [ ] Click a Field → existing field-edit page
- [ ] Click an Agent / Skill / Role → existing detail page
- [ ] Click a View / Page layout / Navigation menu item → new read-only
detail page; subsections (Fields/Filters/Sorts for views, per-tab
widgets for page layouts) populate correctly
- [ ] Breadcrumbs on every entity detail page have 5 crumbs ending in
`<Category> / <Entity name>`
- [ ] Logic function Triggers tab: toggle each trigger type on/off, see
the Sample input preview update; for installed apps, sections render as
read-only
- [ ] Test tab: each "Simulate trigger" button prefills the JSON editor
with the matching payload shape
- [ ] Functions list: a function configured as `post-install` shows
"Post-install" in the Trigger column
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
## Summary
- Rename safeParseRelativeDateFilterJSONStringified to
safeParseRelativeDateFilterJsonStringified
- Update the matching utility file, exports, tests, and workflow usages
Part of #19839.
## Validation
- CI passed
## Summary
The AI chat history tab in the navigation drawer (and the corresponding
"New chat" icon in the mobile bottom bar) were rendered for every
authenticated user, regardless of whether they had `AI_SETTINGS`
permission. The actual chat content load is already gated — see
`AgentChatThreadInitializationEffect` — but the entry points were not,
so users without AI access saw a tab they could open and find empty.
This regressed when the `IS_AI_ENABLED` feature flag was removed in
#19916. The flag check was the only gate; nothing replaced it with a
permission check.
## Fix
Three small changes, all using
`useHasPermissionFlag(PermissionFlagType.AI_SETTINGS)`:
- **`MainNavigationDrawerTabsRow`** — return `null` when the user
lacks AI access. This row only contains AI controls (the chat
history tab pill + the "New chat" button), so without AI access
there is nothing meaningful to render.
- **`MainNavigationDrawer`** — only render
`NavigationDrawerAiChatContent`
when the user has AI access **and** the active tab is
`AI_CHAT_HISTORY`. This is defensive: with the tabs row hidden the
user can no longer switch to AI, but the active-tab atom could still
carry `AI_CHAT_HISTORY` from a previous state (notably right after
stopping impersonation of a user who had AI). Without this guard,
the AI panel would briefly stay rendered.
- **`MobileNavigationBar`** — drop the `newAiChat` item when the user
lacks AI access.
Surfaced by the same customer report that motivated #20088
(stop-impersonation
state cleanup), but this is a separate, pre-existing bug — admins
without AI permission see the tab regardless of impersonation.
## Test plan
- [ ] As a user **with** `AI_SETTINGS` permission, verify the AI chat
tab and "New chat" button still appear and work in both the
desktop sidebar and mobile bottom bar
- [ ] As a user **without** `AI_SETTINGS` permission, verify:
- the tabs row at the top of the sidebar is gone (only the
navigation menu shows below)
- the "New chat" mobile bottom-bar icon is gone
- the AI chat panel does not appear even after navigating away from
a state where it was previously active
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
- Add user avatars and workspace logos to the admin general table and
workspace member detail view
- Show app icons in the admin app registrations table and reuse the
shared application display component
- Expose the needed avatar and logo fields through admin GraphQL queries
and backend lookup/statistics services
- Keep workspace fallback behavior consistent when no logo is set and
clean up a few local table styling duplicates
## Testing
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`
- Manual UI verification of the admin general, workspace detail, and
apps tables
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Introduction
In aim to reduce and optimize the number of twenty-front build we do
during our cd process and allow twenty-front build promotion
### Build time
**Nothing is baked.** The `build/` directory is a clean, env-agnostic
artifact. `index.html` contains the empty placeholder:
```html
<script id="twenty-env-config">
window._env_ = {
// This will be overwritten
};
</script>
```
The JS bundles contain no hardcoded server URL.
---
### Deploy mode 1: Frontend served by the backend (Docker / NestJS)
1. Container starts, NestJS boots in `main.ts`
2. `generateFrontConfig()` runs, reads `process.env.SERVER_URL`
3. Rewrites `dist/front/index.html`, replacing the placeholder with:
```html
<script id="twenty-env-config">
window._env_ = {
REACT_APP_SERVER_BASE_URL: "https://api.example.com"
};
</script>
```
4. NestJS serves the static `dist/front/` directory
5. Browser loads `index.html`, `window._env_` is set before the app JS
executes
6. `src/config/index.ts` reads `window._env_.REACT_APP_SERVER_BASE_URL`
and uses it
---
### Deploy mode 2: Frontend served standalone (CDN / nginx / static
server)
1. Take the `build/` artifact as-is
2. Before serving, run at deploy time:
```bash
REACT_APP_SERVER_BASE_URL=https://api.example.com sh
./scripts/inject-runtime-env.sh
```
3. This does the same `sed` replacement on `build/index.html`
4. Serve the `build/` directory with your static server of choice
5. Same resolution in the browser:
`window._env_.REACT_APP_SERVER_BASE_URL` is picked up by
`src/config/index.ts`
---
### Fallback: no injection at all
If neither mechanism runs (e.g. local dev with `vite dev`),
`window._env_.REACT_APP_SERVER_BASE_URL` is `undefined`, and
`getDefaultUrl()` kicks in:
- **Localhost**: returns `http://localhost:3000`
- **Non-localhost**: returns same-origin (`window.location.origin`)
## Summary
- Hides the `exportRecords`, `exportView`, and `importRecords` command
menu actions from
users whose role does not hold the matching `EXPORT_CSV` / `IMPORT_CSV`
permission flag.
- Exposes the current user's role permission flags to
`conditionalAvailabilityExpression`
by adding `permissionFlags: Record<string, boolean>` to
`CommandMenuContextApi`, mirroring
how `featureFlags` is already accessible.
- Adds a `2.1.0` workspace upgrade command that rewrites the three
existing rows on every
active/suspended workspace.
## Before
<img width="1294" height="287" alt="Screenshot 2026-04-22 at 19 37 40"
src="https://github.com/user-attachments/assets/11ca8635-14d7-40a0-9ca0-76329c54e3c6"
/>
## After
<img width="1283" height="285" alt="Screenshot 2026-04-22 at 19 32 25"
src="https://github.com/user-attachments/assets/5e49fa8a-4541-42ee-96da-4c1de7d00aae"
/>
## Summary
Fixes Sentry errors of the form:
> \`messages.3: \`tool_use\` ids were found without \`tool_result\`
blocks immediately after: srvtoolu_…. Each \`tool_use\` block must have
a corresponding \`tool_result\` block in the next message.\`
### Root cause
When the model invokes a **provider-hosted tool** (e.g. Anthropic's
native \`web_search\` — note the \`srvtoolu_\` ID prefix), the AI SDK
marks the resulting \`UIMessagePart\` with \`providerExecuted: true\`.
\`convertToModelMessages\` uses that flag to emit the
tool_use/tool_result pair *inside the same assistant message* — the
format Anthropic requires for server-side tools.
Our \`AgentMessagePart\` persistence was dropping \`providerExecuted\`
on the way to the DB (and re-hydration didn't know to set it). On the
next turn, \`convertToModelMessages\` treated the rehydrated part as a
client-side tool call, splitting it into \`assistant(tool_use)\` +
\`user(tool_result)\` — which Anthropic then rejects with the error
above.
### Fix
- Add nullable \`providerExecuted BOOLEAN\` column on
\`core.agentMessagePart\` via a fast instance command.
- Surface the field on \`AgentMessagePartDTO\` (GraphQL).
- Preserve it through \`mapUIMessagePartsToDBParts\` (server) and both
\`mapDBPartToUIMessagePart\` mappers (server + frontend).
- Include it in \`GET_CHAT_MESSAGES\` and \`GET_AGENT_TURNS\`
selections.
- Regenerate \`generated-metadata/graphql.ts\`.
### Backwards compatibility
Existing rows have \`NULL providerExecuted\` and round-trip as the
omitted flag — which is exactly the pre-fix behaviour for tool parts
that were never provider-executed. Only *new* assistant messages using
\`web_search\` (or other provider-hosted tools) will write \`true\`, and
those are the only ones that were breaking.
## Test plan
- [x] \`npx tsgo\` typecheck — server + front clean
- [x] \`oxlint\` + \`prettier --check\` on all touched files — clean
- [x] \`npx nx run twenty-server:database:migrate:prod\` runs the new
instance command locally; \`providerExecuted\` column present on
\`core.agentMessagePart\`
- [x] Regenerated \`generated-metadata/graphql.ts\` —
\`providerExecuted\` wired into both queries and \`AgentMessagePart\`
type
- [ ] Manual: start a chat with Anthropic web_search enabled, invoke the
tool in turn 1, reply in turn 2 — should not throw the srvtoolu error
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Context
Phase 1 of removing the legacy `gridPosition` field from
`PageLayoutWidget` in favor of the new `position` discriminated union
(`grid` / `vertical-list` / `canvas`). This PR is purely additive —
`gridPosition` is still required and read everywhere; we just guarantee
that every widget now also has a non-null `position` so a follow-up PR
can drop `gridPosition` cleanly.
## Changes
- **Slow instance command**
`BackfillPageLayoutWidgetPositionSlowInstanceCommand` (2.1.0):
for every `core.pageLayoutWidget` row where `position IS NULL`, copies
`gridPosition`
into `position` with `layoutMode: 'GRID'`. Historically only grid
widgets used
`gridPosition`, so a single SQL update covers every existing row.
- **`PageLayoutDuplicationService`**: when duplicating a widget, also
forwards
`originalWidget.position` (was previously only forwarding
`gridPosition`).
- **Frontend default layouts** (10 `Default*PageLayout.ts` files): added
a `position`
sibling to every widget, matching the parent tab's `layoutMode` —
`VERTICAL_LIST` widgets
get `{ layoutMode, index }`, `CANVAS` widgets get `{ layoutMode }`
<img width="338" height="226" alt="Screenshot 2026-04-24 at 16 23 17"
src="https://github.com/user-attachments/assets/c3319318-f1b8-4271-96b4-196b209a1f5e"
/>
When a record has no associated `pageLayoutId`, the FE falls back to a
hardcoded default page layout (`DEFAULT_*_RECORD_PAGE_LAYOUT`). These
mocks use non-UUID ids for the layout, tabs, and widgets
(e.g. `default-person-page-layout`).
Today nothing distinguishes these fallbacks from real layouts at edit
time. Clicking "Edit layout" flips the global customization mode,
`PageLayoutRecordPageCustomizationSessionRegistrationEffect` registers
the mock id, and on Save `useSaveLayoutCustomization` fires
`UpdatePageLayoutWithTabsAndWidgets` with those mock ids — the BE
rejects them and we end up in a partial-save state (nav / command menu
commit while the page layout fails).
This PR keeps fallback record page layouts in read mode even when
global layout-edition mode is on, and defends the save loop so mock
ids can never be sent to the BE.
## Non editable "base" record page layout (=mock)
<img width="1508" height="616" alt="Screenshot 2026-04-24 at 10 35 02"
src="https://github.com/user-attachments/assets/34b093d1-f4bb-4076-ab8c-ce97ecb945a4"
/>
## Editable record page layout
<img width="1512" height="597" alt="Screenshot 2026-04-24 at 10 35 20"
src="https://github.com/user-attachments/assets/5ff6f3ff-79a5-4e6f-aa1c-01c7e09273b3"
/>
## Summary
- Adds a **Billing** tab on the admin-panel workspace detail page that
surfaces Stripe customer + active subscription details (status, plan,
interval, current period, trial, cancellation, line items, credit
balance). Tab is gated on `IS_BILLING_ENABLED` both in the backend
service and in the frontend tab list — completely hidden on instances
where billing is disabled.
- Renders a **workspace avatar next to the name** in the admin Top
Workspaces list by plumbing the workspace `logo` field through the admin
DTO, statistics SQL query, and generated admin GraphQL types.
- **Read-only** by design: no Stripe API calls, no mutations — data
comes from the existing \`BillingCustomerEntity\` /
\`BillingSubscriptionEntity\` / \`BillingPriceEntity\` tables via
\`BillingSubscriptionService.getCurrentBillingSubscription\`.
### What the tab shows
- **Customer** container — Stripe customer ID (with link to the Stripe
dashboard, monospaced), credit balance (formatted, from
\`creditBalanceMicro\`).
- **Subscription** container — status tag (color-coded), plan tag,
billing interval, current period range, trial range (if trialing),
\`cancelAtPeriodEnd\` / \`cancelAt\` / \`canceledAt\` (only when set),
Stripe subscription ID (external link).
- **Line items** — one card per subscription item with product name,
product key tag, seats (if quantity), credits per period (for metered),
unit price (formatted with currency).
### Design choices
- Styling matches the user-facing billing page
(\`SubscriptionInfoContainer\` + \`Tag\` + \`H2Title\` + \`Section\`) —
no new UI primitives.
- Currency is rendered inline with amounts via \`Intl.NumberFormat\`
(e.g. \`\$19.00\`) instead of as a separate row.
- Uses the generated admin GraphQL types
(\`WorkspaceBillingAdminPanelQuery\`, \`SubscriptionStatus\`,
\`SubscriptionInterval\`) — no hand-typed response shapes.
## Test plan
- [x] \`npx nx typecheck twenty-server\` — passes
- [x] \`npx nx typecheck twenty-front\` — passes
- [x] oxlint + prettier on all touched files — clean
- [x] \`graphql:generate --configuration=admin\` — regenerated; new
\`workspaceBillingAdminPanel\` query + \`logo\` field on
\`AdminPanelTopWorkspace\` appear in \`generated-admin/graphql.ts\`
- [x] Backend GraphQL schema introspection shows
\`workspaceBillingAdminPanel\` query on \`/admin-panel\`
- [x] Direct GraphQL call with seeded \`BillingCustomer\` +
\`BillingSubscription\` + \`BillingPrice\` rows returns the expected
shape (\`status: "Trialing"\`, plan \`PRO\`, items with
quantity/unitAmount/includedCredits, trial period dates)
- [x] With \`IS_BILLING_ENABLED=false\` (default) the Billing tab is
hidden — verified in the admin panel UI
- [x] Top Workspaces list renders workspace avatars next to names —
verified in the admin panel UI
- [ ] Smoke test the Billing tab render in a real instance that has
\`IS_BILLING_ENABLED=true\` + live Stripe data (skipped locally due to
dev-env auth friction after toggling billing/multi-workspace; recommend
a reviewer check)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**PR 1 of 2.** Follow-up PR ships the Exa app, sets it as a default
pre-installed app, and removes the current `WebSearchTool` /
`WebSearchService` / `ExaDriver`. This PR adds the plumbing; no
user-visible change yet.
## Summary
- Server admins can declare a list of npm app packages to auto-install
on every new workspace and backfill onto existing workspaces via CLI.
- Server-level secrets (like Exa's API key) live on the
`ApplicationRegistration` (one row per server, encrypted) and are
injected into logic function execution env at runtime. No more
per-workspace storage of global secrets.
- A generic `POST /app/billing/charge` endpoint lets app logic functions
emit workspace usage events for metered features. Exa uses it in PR 2;
future apps (call recorder, etc.) reuse it.
- `LogicFunctionToolProvider` tool name prefix changes `logic_function_`
→ `app_`. Shorter, accurate (they come from installed apps).
## What's in this PR
**Logic function executor — server-level variables**
- `LogicFunctionExecutorService.getExecutionEnvVariables` now resolves
env vars in the order: hardcoded defaults →
`ApplicationRegistrationVariable[]` (server-level) →
`ApplicationVariable[]` (workspace-level override). The manifest
`serverVariables` schema has existed; this closes the loop.
**Config**
- `PRE_INSTALLED_APPS` — comma-separated list of npm packages. Default:
empty.
**\`PreInstalledAppsService\`** (new module)
- \`onApplicationBootstrap()\` — fetches each package's manifest from
the app registry CDN, upserts an \`ApplicationRegistration\`, and seeds
declared \`serverVariables\` from matching env vars (e.g.
\`EXA_API_KEY\` env → encrypted registration variable).
- \`installOnWorkspace(workspaceId)\` — installs all pre-installed apps
on a single workspace. Tolerates per-app failures.
**Auto-install on new workspace activation**
- \`WorkspaceService.prefillCreatedWorkspaceRecords\` invokes
\`installOnWorkspace\` after prefilling standard records. Non-blocking
on failure.
**Backfill CLI command**
- \`install-pre-installed-apps\` — iterates active and suspended
workspaces, installs pre-installed apps that aren't yet installed.
Idempotent. Run after changing \`PRE_INSTALLED_APPS\`.
**App billing endpoint**
- \`POST /app/billing/charge\`. Authenticated via \`APPLICATION_ACCESS\`
token (already injected into logic function execution env as
\`DEFAULT_APP_ACCESS_TOKEN\`). Body: \`{ creditsUsedMicro, quantity,
unit, operationType, resourceContext? }\`. Emits \`USAGE_RECORDED\` with
\`applicationId\` as \`resourceId\`. Generic — reusable by any app.
**Tool name prefix**
- \`LogicFunctionToolProvider.buildLogicFunctionToolName\` now produces
\`app_<name>\` instead of \`logic_function_<name>\`. Only affects tools
sourced from logic functions; other tool providers unchanged.
## Stats
- 16 files, +501 / −2
- 7 new files (1 command, 1 service × 2, 1 controller, 1 DTO, 2 modules)
- Typecheck: 7 pre-existing errors, zero new
- Prettier clean
## Behavior deltas
- **\`PRE_INSTALLED_APPS\` default = empty**: existing servers see no
change on merge.
- **\`ApplicationRegistrationVariable\` is now read by the executor**:
apps that were using manifest \`serverVariables\` but expecting them to
be ignored by the executor will now see them injected. No apps ship with
\`isTool: true\` logic functions today, so this is latent — first
consumer is Exa in PR 2.
- **Tool prefix**: currently no logic-function tools are named
\`logic_function_*\` in any production flow. The prefix change affects
only future tools emitted by \`LogicFunctionToolProvider\`.
## Risks
- **CDN unavailability at startup**: if the app registry CDN is down,
\`ensureRegistrationsExist\` logs warnings but doesn't block server
start. Installation on new workspaces during this window will find no
registrations and log a non-blocking error. Backfill command can retry
after CDN recovers.
- **Cold-start overhead**: \`ensureRegistrationsExist\` is called once
per process on bootstrap. Current configurable default is empty, so zero
overhead. When an admin sets \`PRE_INSTALLED_APPS\`, they accept one
HTTP call per package at boot.
- **Server-level variables flow**:
\`ApplicationRegistrationVariable.encryptedValue\` is shared by all
workspaces of a server. Appropriate for a single-tenant Exa key. Not
appropriate for per-tenant keys — those go in workspace-level
\`ApplicationVariable\` and override.
## Test plan
- [ ] \`npx nx typecheck twenty-server\` passes (verified: 7
pre-existing unrelated errors, zero new)
- [ ] Set \`PRE_INSTALLED_APPS=@twenty-apps/hello-world\` (or any real
npm-published app), \`HELLO_WORLD_API_KEY=xxx\`, restart server:
\`ApplicationRegistration\` row is upserted,
\`ApplicationRegistrationVariable\` for HELLO_WORLD_API_KEY is populated
(encrypted).
- [ ] Create a new workspace: the app is auto-installed,
\`ApplicationEntity\` row created, \`LogicFunctionEntity\` rows created.
- [ ] Existing workspace: run \`yarn nx run twenty-server:command
install-pre-installed-apps\`: apps install across all workspaces,
idempotent on re-run.
- [ ] Trigger a logic function that reads
\`process.env.HELLO_WORLD_API_KEY\`: value resolves from the
server-level \`ApplicationRegistrationVariable\`.
- [ ] Log a charge from the handler: \`POST /app/billing/charge\` with
\`Authorization: Bearer \$DEFAULT_APP_ACCESS_TOKEN\` body
\`{creditsUsedMicro: 1000, quantity: 1, unit: "INVOCATION",
operationType: "WEB_SEARCH"}\` → returns \`{success: true}\`,
\`USAGE_RECORDED\` event emitted with correct
\`resourceId=applicationId\`.
- [ ] Tool name generated by \`LogicFunctionToolProvider\` starts with
\`app_\`.
## What's NOT in this PR (PR 2 scope)
- The Exa app itself (\`packages/twenty-apps/...\` directory)
- Removing \`WebSearchTool\`, \`WebSearchService\`, \`ExaDriver\`,
\`web-search\` module
- Removing \`WEB_SEARCH_DRIVER\` config var
- Removing the current \`exa_web_search\` entry in
\`ActionToolProvider\`
- Chat preload list updated to \`app_exa_web_search\`
- Frontend \`getToolDisplayMessage\` branch for \`app_exa_web_search\`
- Setting \`PRE_INSTALLED_APPS\` default to include \`@twenty-apps/exa\`
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
\`SettingsSkeletonLoader\` wraps its content in \`PageHeader\` +
\`PageBody\`, which is right for **full-page replacement** (user detail,
config variable detail, etc.) but renders as a large empty stub with one
tiny floating bar when placed **inline inside a section that's already
scaffolded**. That's what showed up in Recent Users, Top Workspaces, and
the Chats tab in the admin panel — a jarring white page flash where a
few row placeholders should be.
This PR adds \`SettingsAdminSectionSkeletonLoader\` — a small,
configurable-row-count skeleton that uses the project's standard
\`SkeletonTheme\` pattern (\`theme.background.tertiary\` /
\`theme.background.transparent.lighter\` + \`borderRadius: 4\`, matching
\`PageContentSkeletonLoader\` and \`SettingsAdminTabSkeletonLoader\`)
and renders row-height bars that match \`TableRow\` spacing. Swaps it
into the three inline call sites.
Full-page usages of \`SettingsSkeletonLoader\` are unchanged.
### Changed
- **New**:
\`packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminSectionSkeletonLoader.tsx\`
- **Swapped** (3 inline loading states): \`SettingsAdminGeneral.tsx\`
(Recent Users + Top Workspaces), \`SettingsAdminWorkspaceDetail.tsx\`
(Chats tab)
## Test plan
- [x] typecheck (\`npx nx typecheck twenty-front --skip-nx-cache\`) —
clean
- [x] oxlint — 0 warnings
- [x] prettier — clean
- [ ] Visual: navigate to admin panel, observe Recent Users / Top
Workspaces / Chats tab loading — should see a tight stack of row-height
placeholder bars instead of a big empty page stub
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fixed the dark-mode regression in the layout customization banner at
packages/twenty-front/src/modules/layout-customization/components/LayoutCustomizationBar.tsx:
- The banner's background is themeCssVariables.color.blue
(theme-independent), so its text color must be theme-independent too.
- Replaced color: ${themeCssVariables.font.color.inverted} (which
resolves to near-black in dark mode) with color:
${GRAY_SCALE_LIGHT.gray1} (always white), matching the convention used
in AnimatedButton.tsx for text on colored backgrounds.
## Before
<img width="1508" height="185" alt="Screenshot 2026-04-22 at 19 50 03"
src="https://github.com/user-attachments/assets/b0a95fc2-05d5-4207-9d72-64a83daf94ae"
/>
## After
<img width="1511" height="190" alt="Screenshot 2026-04-22 at 19 49 39"
src="https://github.com/user-attachments/assets/9ce33353-412f-4db2-9322-a6f293f6f1b4"
/>
## Context
- The FIELDS widget resolved every viewField against
objectMetadataItem.fields, which
includes deactivated field metadata — so fields deactivated after being
added to a view
kept rendering on records.
- Same leak existed in the layout editor: deactivated fields appeared as
toggleable hidden
viewFields, and newly-deactivated object fields were auto-proposed via
the "missing
fields" flow.
## Fix
- pre-filter objectMetadataItem.fields to field.isActive at each entry
point
(useFieldsWidgetGroups, useFieldsWidgetEditorGroupsData,
useFieldsWidgetHiddenFields) and
inside buildDefaultFieldsWidgetGroups for the no-view fallback.
## Summary
- Refresh the settings application visuals with new light/dark PNG
covers for the data model card
- Replace the custom and standard application carousel assets with the
new provided illustrations
- Align app chips, type tags, and application detail previews with the
updated icon and description treatment
- Keep the data model cover container and overlay button behavior intact
while swapping the underlying imagery
## Testing
- Not run (not requested)
- Existing frontend typecheck and formatting checks were exercised
during implementation
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Summary
- Today `WEB_SEARCH_PREFER_NATIVE` forces a **mutual exclusion**: either
the custom Exa tool preloads as `web_search` or the SDK-native
`web_search` binds. Same name, different backends.
- This PR lets them **coexist**. Custom Exa becomes `exa_web_search`;
native keeps `web_search`. The model picks based on tool descriptions.
- `WEB_SEARCH_PREFER_NATIVE` and `shouldUseNativeSearch()` are deleted.
Exa enablement follows `WEB_SEARCH_DRIVER` (existing). Native enablement
follows the agent's `modelConfiguration.webSearch.enabled` (existing).
## Key changes
**Config / service**
- Deleted `WEB_SEARCH_PREFER_NATIVE` (config-variables.ts)
- Deleted `WebSearchService.shouldUseNativeSearch()`
- `WebSearchService.isEnabled()` unchanged — still gates Exa
availability
**Custom tool rename**
- `ActionToolProvider.toolMap`: `'web_search'` → `'exa_web_search'`
- Descriptor name matches
- `WebSearchTool.description` rewritten to position Exa as
structured/entity-aware, complementary to native
**Native tool binder**
- `NativeToolBinder.bind()` drops the `shouldUseNativeSearch` gate.
Per-agent `modelConfiguration.webSearch.enabled` (inside
`getNativeModelTools`) stays authoritative.
**Chat**
- Preload list now always includes `exa_web_search` —
`ActionToolProvider` silently skips the descriptor when Exa is disabled,
so `getToolsByName` degrades gracefully
- Native tools always attempted; returns empty ToolSet when the model
doesn't support them
- `directTools = { ...preloadedTools, ...nativeSearchTools }` — both
present when both enabled
- `billNativeWebSearchUsage` called unconditionally (the function
already short-circuits on count ≤ 0)
**Workflow agent**
- Same unconditional billing pattern
- `WebSearchService` dependency removed
**System prompt**
- Dropped the special-cased `web_search` branch. Preloaded tools list
uniformly now.
**Frontend**
- `exa_web_search` reuses the same "Searching the web for X" display as
native
- Test coverage added
## Billing isolation (verified)
- `countNativeWebSearchCallsFromSteps` counts `toolName ===
'web_search'` only. After the rename, only native calls match. Exa calls
(`exa_web_search`) are billed separately via
`WebSearchService.emitUsageEvent` inside `search()`.
- No double-billing path.
## Behavior deltas (intended)
| Scenario | Before | After |
|---|---|---|
| Anthropic model + Exa enabled + PREFER_NATIVE=true | native only |
**both** |
| Anthropic + Exa enabled + PREFER_NATIVE=false | Exa only (as
`web_search`) | **both** |
| Non-native model + Exa enabled | Exa as `web_search` | Exa as
`exa_web_search` |
| Any model + Exa disabled + native supported | native only | native
only |
| Workflow agent with `webSearch.enabled=true` + Anthropic + Exa enabled
| native only | **both** |
## Known regression (accepted)
Customers who set `WEB_SEARCH_PREFER_NATIVE=false` to force Exa-only
will now **also** see native `web_search` if the model supports it.
There's no chat-level kill switch after this PR. Per discussion, this is
accepted — future model-level capability gating (in the model JSON) will
be the right place for that control.
## Stats
- 10 files, +63 / −73 (net deletion)
- Typecheck clean (server: 7 pre-existing unrelated, front: 13
pre-existing unrelated — zero new either side)
- Prettier clean
## Test plan
- [ ] `npx nx typecheck twenty-server` and `npx nx typecheck
twenty-front` pass
- [ ] With Anthropic + Exa enabled: chat shows both `web_search` and
`exa_web_search` in preloaded list; model can call either
- [ ] With Anthropic + Exa disabled: chat shows only native `web_search`
- [ ] With non-native model + Exa enabled: chat shows only
`exa_web_search`
- [ ] Workflow agent with `modelConfiguration.webSearch.enabled=true` +
Exa enabled: both available
- [ ] Billing: native calls billed via `billNativeWebSearchUsage`; Exa
calls billed via `WebSearchService.emitUsageEvent`; no double-billing
- [ ] Frontend: `exa_web_search` renders "Searching the web for X" the
same as `web_search`
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#12847
Implements a two-mode focus management pattern for `SelectableList`
components with search inputs, resolving the conflict between text input
cursor movement and grid/list navigation.
### How it works
**Input mode** (search input focused):
- Left/right arrow keys move the text cursor normally
(`enableOnFormTags: false` on ArrowLeft/ArrowRight hotkeys)
- Up/down arrow keys blur the input and transfer focus to the grid,
entering grid mode
**Grid mode** (search input blurred):
- All arrow keys navigate the selectable list grid
- Pressing up arrow from the top row clears the grid selection and
refocuses the search input, returning to input mode
- Typing any printable character refocuses the search input (wildcard
hotkey with `enableOnFormTags: false`)
### Demo
https://github.com/user-attachments/assets/825ad603-a5f8-4863-8269-3ecf35965847https://github.com/user-attachments/assets/9d07346d-18a0-40fa-8874-21040c11f03d
## Summary
- Fix side panel hotkeys (Ctrl+K, Escape, etc.) breaking when opening
records from the record index table
- Ensure `side-panel-focus` is always restored in the focus stack when
navigating within an already-open side panel
- Remove stale `globalHotkeysConfig` on `record-index` focus item that
persisted after the side panel closed
## Problem
When clicking records in the table to open them in the side panel,
`useLeaveTableFocus` called `resetFocusStackToRecordIndex` which wiped
the entire focus stack, including the `side-panel-focus` entry. Since
`openSidePanel` early-returned when the panel was already open,
`side-panel-focus` was never restored. Additionally,
`resetFocusStackToRecordIndex` set `enableGlobalHotkeysWithModifiers:
false` on the remaining `record-index` item when the side panel was
open, and this stale config persisted after the panel closed,
permanently blocking all hotkeys.
## Fix
- **`useNavigateSidePanel.ts`**: Move `pushFocusItemToFocusStack` before
the `isSidePanelOpened` early-return so the side panel's focus entry is
always present in the stack
- **`useResetFocusStackToRecordIndex.ts`**: Always set
`enableGlobalHotkeysWithModifiers: true` on the `record-index` item.
Hotkey scoping when the side panel is open is handled by
`side-panel-focus` sitting on top of the focus stack
https://github.com/user-attachments/assets/ad25befb-338d-4166-9580-18d4e92d6f9b
## Summary
- AI is now GA, so the public/lab `IS_AI_ENABLED` flag is removed from
`FeatureFlagKey`, the public flag catalog, and the dev seeder.
- Drops every backend `@RequireFeatureFlag(IS_AI_ENABLED)` guard (agent,
agent chat, chat subscription, role-to-agent assignment, workflow AI
step creation) and the now-unused `FeatureFlagModule`/`FeatureFlagGuard`
wiring in the AI and workflow modules.
- Removes frontend gating from settings nav, role
permissions/assignment/applicability, command menu hotkeys, side panel,
mobile/drawer nav, and the agent chat provider so AI UI is always on.
Tests and generated GraphQL/SDK schemas updated accordingly.
## Test plan
- [x] `npx nx typecheck twenty-shared`
- [x] `npx nx typecheck twenty-server`
- [x] `npx nx typecheck twenty-front`
- [x] `npx nx lint:diff-with-main twenty-server`
- [x] `npx nx lint:diff-with-main twenty-front`
- [x] `npx jest --config=packages/twenty-server/jest.config.mjs
feature-flag`
- [x] `npx jest --config=packages/twenty-server/jest.config.mjs
workspace-entity-manager`
- [ ] Manual smoke test: AI features still accessible without any flag
row in `featureFlag`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
- Override the `AppChip` label so the Twenty standard application always
renders as `Standard` and the workspace custom application always
renders as `Custom`, instead of leaking each app's underlying name (e.g.
`Twenty Eng's custom application`).
- Detection mirrors the logic already used in
`useApplicationAvatarColors`, relying on
`TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER` /
`TWENTY_STANDARD_APPLICATION_NAME` and
`currentWorkspace.workspaceCustomApplication.id`.
- The `This app` label for the current application context and the
original `application.name` fallback for any other installed app are
preserved.
## Affected UI
- Settings → Data model → Existing objects (App column).
- Anywhere else `AppChip` / `useApplicationChipData` is used.
## Context
ActivityTargetsInlineCell passed editModeContent via
RecordInlineCellContext, but that context key was no longer read.
Fix aligns the code with the rest of the codebase.
---------
Co-authored-by: Charles Bochet <charles@twenty.com>