Compare commits

...
Author SHA1 Message Date
neo773 f5717752be wip 2026-02-12 02:38:33 +05:30
neo773 24796e9355 refactor caldav 2026-02-12 01:42:59 +05:30
15fc850212 Remove redundant self-build from Nx targets that compile on the fly (#17851)
## Summary

- Changed 6 Nx targets (`start`, `start:debug`, `typeorm`, `ts-node`,
`database:migrate`, `database:migrate:revert`) from `dependsOn:
["build"]` to `dependsOn: ["^build"]` to eliminate a redundant full SWC
compilation step (~30-60s per invocation).
- These targets all use `nest start --watch`, `ts-node`, or TypeORM's
CLI (which runs under ts-node), so they already compile TypeScript
themselves. The `build` dependency was causing `rimraf dist && nest
build` to run first, only for the target's own command to recompile
everything again.
- Fixed `start:debug` to call `nest start --watch --debug` directly
instead of routing through `nx start --debug`, which would trigger yet
another build cycle.

Note: targets that run pre-compiled code from `dist/` (like
`database:reset`) intentionally keep `dependsOn: ["build"]`.

## Test plan

- [ ] Run `npx nx start twenty-server` and verify the server starts with
only one SWC compilation pass instead of two
- [ ] Run `npx nx start:debug twenty-server` and verify the debugger
attaches correctly
- [ ] Run `npx nx run twenty-server:database:migrate` and verify
migrations run correctly
- [ ] Run `npx nx run twenty-server:database:reset twenty-server` and
verify it still builds before running (unchanged)


Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-11 17:38:22 +00:00
e6d5df751b i18n - translations (#17874)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-11 18:25:44 +01:00
5b4fed1afe Fix redirect to deleted workspace subdomain after workspace deletion (#17865)
## Summary
- After deleting a workspace, the app was incorrectly redirecting to the
deleted workspace's subdomain (e.g. `myworkspace.ourapp.com/sign-in-up`)
because `signOut()` only performs a client-side React Router navigation
which stays on the current domain.
- Added an explicit `redirectToDefaultDomain()` call after sign out in
the workspace deletion flow, which does a hard browser redirect to the
base domain (e.g. `app.ourapp.com`).

## Test plan
- [ ] Delete a workspace in a multi-workspace environment
- [ ] Verify the browser redirects to the base domain (`app.ourapp.com`)
instead of staying on the deleted workspace's subdomain
- [ ] Verify normal sign-out (without workspace deletion) still works as
expected

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-11 18:25:27 +01:00
e757a1418d Fix hardcoded colors in 2FA verification screen for dark mode (#17868)
## Summary
- The dash separator and blinking caret in the sign-in 2FA OTP input had
hardcoded `black` and `white` background colors, making them invisible
in dark mode (black on black / white on white).
- Replaced with theme-aware colors (`theme.font.color.light` for the
dash, `theme.font.color.primary` for the caret) to match the existing
settings 2FA component.

## Test plan
- [ ] Open the 2FA verification screen in dark mode and verify the dash
between digit groups is visible
- [ ] Verify the blinking caret is visible in dark mode
- [ ] Confirm both still look correct in light mode

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-11 18:23:42 +01:00
EtienneandGitHub 2f298307f9 Handle 413 with user friendly message (#17870)
Add friendly message for 413 errors. Currently, 413 errors originate
from the nginx server.
2026-02-11 17:03:22 +00:00
WeikoandGitHub c15536f9f8 Fix page layout seeding for record page layouts (#17871)
## Context
Fix broken record page layout seeding. This was not detected by the CI
because it doesn't have the env variable yet.

Following the same mechanism as labelIdentifier in object for circular
dependency resolution
2026-02-11 16:56:21 +00:00
Paul RastoinandGitHub b2f7c745f8 Solo transaction application synchronization service refactor (#17864)
# Introduction
Refactoring the application sync service to be making a single validate
build and run transaction instead of calling all the services n times
thanks to the universal workspace migration refactor that allow doing so

## What's next
Migrating all below entities to by syncableEntities so they can be
universalised too ( right now they're still calling the services n times
and won't be returned in the workspace migration )
-
[objectPermission](https://github.com/twentyhq/core-team-issues/issues/2223)
-
[fieldPermission](https://github.com/twentyhq/core-team-issues/issues/2224)
-
[permissionFlag](https://github.com/twentyhq/core-team-issues/issues/2225)
2026-02-11 16:30:01 +00:00
0ee63a0525 i18n - translations (#17872)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-11 17:35:20 +01:00
18880f0385 i18n - translations (#17869)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-11 17:05:37 +01:00
Abdul RahmanGitHubCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>Devessier
0902579fbe Feat: Navbar customization (#17728)
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Devessier <[email protected]>
2026-02-11 15:33:51 +00:00
52e57e70fd Add wildcard documentation for like/ilike/containsIlike filters (#17825)
Add documentation for issue #16602 
After discussing with the team (Thomas),
https://discord.com/channels/1130383047699738754/1443986309436936212 we
decided that updating the documentation.
The issue is In compute-where-condition-parts.ts, the like/ilike cases
pass values directly to SQL without adding % wildcards for api using, so
they behave like exact matches.

This PR updates the documentation regarding the use of `like`, `ilike`
and `containsIlike` filters. Instead of auto-wrapping values with %
wildcards in the backend, we are choosing to leave the control to the
API users (%value% or value%).

<img width="651" height="409" alt="image"
src="https://github.com/user-attachments/assets/b3537af6-a0b0-4fff-a86d-a9ae334d628e"
/>

But I add wildcard for `startsWith` and `endsWith` because these
operators have a fixed semantic meaning.

(To see the results, please refresh the cache first, then restart the
server.)

<img width="878" height="458" alt="image"
src="https://github.com/user-attachments/assets/ab0f4e7c-df50-45ef-b1c8-e43c8881a9a3"
/><img width="482" height="288" alt="image"
src="https://github.com/user-attachments/assets/20dc39ee-2417-4ecc-810e-ea0ead33d803"
/>

---------

Co-authored-by: Thomas Trompette <[email protected]>
2026-02-11 15:32:41 +00:00
Thomas TrompetteandGitHub 1e01f15182 Fix code step and logic function step in workflows (#17856)
- AI still often forgets to update the code step after creating it.
Adding a next step
- Starting by loading logic functions, so it avoids creating code steps
when a function exists
- Fix create complete workflow logic. Should not create code steps
directly
2026-02-11 15:08:23 +00:00
EtienneandGitHub d0c1841f0f File - Migrate avatarUrl > avatarFile on person (data migration + logic) + Attachment data migration (#17752)
- Migration command
    - Check IS_FILES_FIELD_MIGRATED:false
    - Check or create avatarFile field
    - Fetch all people with avatarUrl
           - Move (Copy/move) file in storage
           - Create core.file record
           - Update person record
           
    - bonus : attachment migration  : fullPath > file (same logic)
   
- BE logic
    - Add avatarFile field on person

- FE logic 
   - Adapt logic to upload on/display avatarFile data

The whole imageIdentifier logic will be done later
2026-02-11 15:07:05 +00:00
Thomas TrompetteandGitHub 5be64bf4be Remove guard from find logic functions (#17862)
As title
2026-02-11 14:41:40 +01:00
Paul RastoinandGitHub d9dab75052 Do not throw on corrupted labelFieldMetadataIdentifier (#17859)
# Introduction
As we don't enforce any FK on object labelIdentifierFieldMetadataId we
have some that are either null or pointing to non-existing field
metadata resulting in exception thrown at cache computation lvl

Commenting the exception throw until we've closed
https://github.com/twentyhq/core-team-issues/issues/2172

closes https://github.com/twentyhq/core-team-issues/issues/2221
2026-02-11 12:33:38 +00:00
2237273869 Fix spurious logouts by deduplicating concurrent token renewals (#17858)
## Summary

- When returning to the app after idle (or after a deploy), the expired
access token causes multiple simultaneous GraphQL queries to fail with
`UNAUTHENTICATED`. Previously, each failure independently triggered its
own `renewToken` call with the same refresh token. If **any single**
renewal failed (e.g. server briefly slow after a deploy), the `catch`
handler would nuke the session and redirect to sign-in — even if another
concurrent renewal had already succeeded and written valid tokens.
- This adds a shared `renewalPromise` so that only the first
`UNAUTHENTICATED` error triggers a server-side renewal. All concurrent
callers await the same promise and replay their operations once it
resolves. This eliminates redundant refresh token rotation on the server
and removes the race condition where a straggling failure could log out
an already-renewed session.

## Test plan

- [ ] Log in, wait >30 minutes (or manually expire the access token),
then interact with the app — should silently renew without redirect to
sign-in
- [ ] Open browser DevTools Network tab, trigger the above scenario, and
verify only **one** `renewToken` mutation is sent (instead of N)
- [ ] With server temporarily stopped, verify that a genuine renewal
failure still correctly redirects to sign-in (single
`onUnauthenticatedError` call)
- [ ] Open multiple browser tabs, let access tokens expire, interact in
one tab — other tabs should also recover gracefully on their next
request


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
2026-02-11 12:28:52 +00:00
Charles BochetandGitHub 9e21e55db4 Prevent leak between /metadata and /graphql GQL schemas (#17845)
## Fix resolver schema leaking between `/metadata` and `/graphql`
endpoints

### Summary
- Patch `@nestjs/graphql` to support a `resolverSchemaScope` option that
filters resolvers at both schema generation and runtime, preventing
cross-endpoint leaking
- Introduce `@CoreResolver()` and `@MetadataResolver()` decorators to
explicitly scope each resolver to its endpoint
- Move most resolvers (auth, billing, workspace, user, etc.) to the
metadata schema where the frontend expects them; only workflow and
timeline calendar/messaging resolvers remain on `/graphql`
- Fix frontend `SSEQuerySubscribeEffect` to use the default (metadata)
Apollo client instead of the core client

### Problem
NestJS GraphQL's module-based resolver discovery traverses transitive
imports, causing resolvers from `/metadata` modules to leak into the
`/graphql` schema and vice versa. This made the schemas unpredictable
and tightly coupled to module import order.

### Approach
- Added `resolverSchemaScope` to `GqlModuleOptions` via a patch on
`@nestjs/graphql`, filtering in both `filterResolvers()` (runtime
binding) and `getAllCtors()` (schema generation)
- Each resolver is explicitly decorated with `@CoreResolver()` or
`@MetadataResolver()`
- Organized decorator, constant, and type files under `graphql-config/`
following project conventions


Core GQL Schema: (see: no more fields!)
<img width="827" height="894" alt="image"
src="https://github.com/user-attachments/assets/668f3f0f-485e-43f0-92be-4345aeccacb6"
/>

Metadata GQL Schema (see no more getTimelineCalendarEventsFromCompany)
<img width="827" height="894" alt="image"
src="https://github.com/user-attachments/assets/443913db-e5fe-4161-b0e7-4a971cc80a71"
/>
2026-02-11 10:05:24 +00:00
859241d237 Fix merge records page accumulating duplicate morph items (#17705)
## Why
When opening **Merge records** repeatedly, morph items for the same
command-menu page were appended instead of replaced. This could produce
duplicated IDs (e.g. `[A,B,B,A]`) in the merge flow and extra duplicate
tabs in the UI.

## What
- Update `useCommandMenuUpdateNavigationMorphItemsByPage` to replace
page morph items instead of appending existing ones.
- Add regression tests covering:
  - replacing existing morph items for the same page
  - keeping only the latest payload when called twice for the same page

## Notes
I could not run the full workspace tests locally in this environment
because of existing test/build setup issues unrelated to this change
(missing `packages/twenty-front/tsconfig.spec.json` and
`temporal-polyfill` resolution in dependent tasks).

Co-authored-by: remi <[email protected]>
2026-02-11 09:55:19 +00:00
Paul RastoinandGitHub 41e413d7b1 Runner metadata events (#17841)
# Introduction
Followup https://github.com/twentyhq/twenty/pull/17622

Refactoring the actions handler to be returning a metadata event
It has to be done incrementally, as if not update metadata event would
be stale as depends on the incremental action execution order and
optimistic application

## What's next
- Builder should consume and regroup each metadata even in order to
batch emit them ( within a single metadata actions batch order matters )
=> refactoring https://github.com/twentyhq/twenty/pull/17622 in order to
consume runner returned metadata events
2026-02-11 09:09:54 +00:00
b0cb29c11c perf: cache ServerBlockNoteEditor instance in transformRichTextV2Value (#17844)
## Summary
- `transformRichTextV2Value` was calling `await
import('@blocknote/server-util')` and `ServerBlockNoteEditor.create()`
on **every single invocation**, adding ~90ms of overhead each time
(visible as the highest avg-duration frame in profiling at 93.49ms).
- Cache the `ServerBlockNoteEditor` instance at module level so the
dynamic import + creation only happens once for the lifetime of the
process.
- Also removes debug timing instrumentation (`performance.now()`,
`calculateInputSize`, `Logger`) that is no longer needed.

## Test plan
- [ ] Verify rich text fields (blocknote/markdown) still round-trip
correctly on create and update
- [ ] Confirm reduced CPU time for `transformRichTextV2Value` in
profiling


Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-11 09:08:59 +00:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
b4d957306e fix: show user-friendly error message when duplicate invite is sent (#17827)
Fixes #17822

Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-11 00:06:42 +01:00
9c984b137f Fix phone validation performance by using Set/Map instead of Array lookups (#17843)
## Summary
- **`isValidCountryCode`** was using `Array.includes()` on ~250 country
codes (O(n) per call). Replaced with a `Set.has()` lookup (O(1)).
- **`getCountryCodesForCallingCode`** was iterating all ~250 countries
and calling `getCountryCallingCode()` on each one **every invocation**.
Replaced with a precomputed `Map<callingCode, CountryCode[]>` built once
at module load (O(1) per call).

Both functions are called from `transformPhonesValue` on every phone
field mutation, causing cumulative overhead visible in profiling (p95
self-time ~20-35ms).

## Test plan
- [ ] Verify phone field creation/update still works correctly (country
code validation, calling code resolution)
- [ ] Verify spreadsheet import with phone fields still validates
properly
- [ ] Confirm no regression in `isValidCountryCode` or
`getCountryCodesForCallingCode` behavior


Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-10 19:56:43 +00:00
05a6a96b13 i18n - translations (#17842)
Created by Github action

Co-authored-by: github-actions <[email protected]>
2026-02-10 20:23:26 +01:00
16d414590b Lowercase email (#17775)
Fixes https://github.com/twentyhq/core-team-issues/issues/120 #16976
Partially related to #17711

Frontend check surprisingly was one-liner covering both email input and
import files

Migration script will be done in next commit

---------

Co-authored-by: Etienne <[email protected]>
2026-02-10 18:24:49 +00:00
bc72879c70 Fix commands order for v1.17.0 (#17839)
Order should be

1. We delete all the file records (from core.file table)
2. We add the foreign key file / applicationId (pg constraint)
3. We further update the table structure: fullPath is deleted; path is
created; unicity constraint between workspaceId/applicationId/path is
created (pg constraint)
4. we migrate the workflow steps (this will create files in core.file)
5. we backfill the application package (same)

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-02-10 18:47:59 +01:00
bb037e13dd Fix blocknote.map crash with generic field-level RICH_TEXT_V2 handler (#17834)
## Summary

Fixes #17667

- **Root cause**: `ActivityQueryResultGetterHandler` called
`JSON.parse()` on the `blocknote` field and assumed the result was
always an array. When the stored value was valid JSON but not an array
(e.g., `"{}"`), `blocknote.map()` crashed with `blocknote.map is not a
function`, breaking the entire notes page.
- **Fix**: Replaced the object-level `ActivityQueryResultGetterHandler`
(hardcoded for `note`/`task` only) with a generic field-level
`RichTextV2FieldQueryResultGetterHandler` that safely parses blocknote
JSON with `Array.isArray` validation and gracefully skips malformed
values instead of crashing.
- **Bonus**: The new handler works for **all** objects with
`RICH_TEXT_V2` fields (not just `note`/`task`), following the same
pattern as the existing `FilesFieldQueryResultGetterHandler`.

## Changes

| File | Change |
|------|--------|
| `rich-text-v2-field-query-result-getter.handler.ts` | New field-level
handler with safe blocknote parsing |
| `common-result-getters.service.ts` | Register new handler, remove
`note`/`task` object handlers |
| `activity-query-result-getter.handler.ts` | Deleted (replaced by
field-level handler) |
| `rich-text-v2-field-query-result-getter.handler.spec.ts` | 9 tests
covering all edge cases |

## Test plan

- [x] Unit tests pass (9 tests covering: null blocknote, non-string
blocknote, invalid JSON, non-array JSON like `"{}"`, no images, external
URLs, internal URLs, multiple fields)
- [x] Lint passes (`lint:diff-with-main`)
- [x] Typecheck passes


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
2026-02-10 17:07:49 +00:00
Thomas TrompetteandGitHub 17786a3298 SSO - Check if assertion is signed (#17837)
As title
2026-02-10 15:29:34 +00:00
8f91529153 i18n - translations (#17838)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-10 15:24:40 +01:00
e995e84621 [DASHBOARDS] chat agent improvements + new validation layer (#17722)
https://github.com/user-attachments/assets/09550210-76c5-4a40-83b6-9ab785ca10c3



https://github.com/user-attachments/assets/352427fc-0a2a-4f1b-86e9-db99daea0018

---------

Co-authored-by: Félix Malfait <[email protected]>
Co-authored-by: Félix Malfait <[email protected]>
2026-02-10 13:43:36 +00:00
Paul RastoinandGitHub 148584c730 Migrate all remaining workspace migration create action to universal (#17836)
# Introduction
Completely finalize the universal migration at builder and runner
levels.
Which mean that from now on the builder only compares
`universalFlatEntity` and produces universal workspace migration that
the runner ingest

## What's done
Migrated all create metadata remaining for
- agent
- skill
- commandMenuItem
- navigationMenuItem
- fieldMetadata
- objectMetadata
- view
- viewField
- viewFilter
- viewGroup
- viewFilterGroup
- index
- logicFunction
- role
- roleTarget
- pageLayout
- pageLayoutTab
- pageLayoutWidget
- rowLevelPermissionPredicate
- rowLevelPermissionPredicateGroup
- frontComponent
2026-02-10 13:39:37 +00:00
Charles BochetandGitHub b7ff587b5e Query cache instead of database for event listener webhook, logicFunction, triggers (#17824)
## Fix
Replaced direct database queries with existing flat entity map caches
for the two that already have cache infrastructure:
- **CallDatabaseEventTriggerJobsJob** - now uses flatLogicFunctionMaps
cache via WorkspaceCacheService.getOrRecompute(), filtering in memory
for non-deleted logic functions with databaseEventTriggerSettings.
- **CallWebhookJobsJob** - now uses flatWebhookMaps cache via
WorkspaceCacheService.getOrRecompute(), filtering in memory for webhooks
matching the event's operations.
- Note: **WorkflowDatabaseEventTriggerListener** - left as-is since
WorkflowAutomatedTriggerWorkspaceEntity extends BaseWorkspaceEntity (not
SyncableEntity) and has no flat entity map cache infrastructure yet.
2026-02-10 13:14:06 +00:00
2e77a68daf i18n - translations (#17835)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-10 12:44:49 +01:00
BOHEUSandGitHub 382746dd52 Clarify forms usage (#17772)
Fixes https://github.com/twentyhq/core-team-issues/issues/1704
2026-02-10 12:35:37 +01:00
WeikoandGitHub 202a130ac8 revert releasing RLS (#17809) (#17832) 2026-02-10 11:48:59 +01:00
Thomas des FrancsandGitHub 24c6d3fef3 Fix row-level permissions 'Where' left spacing (#17831)
<img width="634" height="289" alt="image"
src="https://github.com/user-attachments/assets/c8fe85e1-ecac-4b57-be36-6c4a5e426c60"
/>
2026-02-10 11:48:32 +01:00
martmullandGitHub 52fe21c04b Fix ci + improvements (#17795)
as title
2026-02-10 11:48:10 +01:00
Paul RastoinandGitHub ee0474e287 Remove standard ids (#17833)
Deadcode
2026-02-10 10:37:55 +00:00
Thomas des FrancsandGitHub 3f202c5c6a Fix downgrade to Pro CTA icon and wording (#17829)
## Summary
- Fixes the downgrade CTA icon for `Switch to Pro` from an up arrow to a
down arrow.
- Fixes wording generation so no `undefined` suffix is concatenated in
downgrade/plan-switch confirmation copy.

## Before

<img width="717" height="535" alt="image"
src="https://github.com/user-attachments/assets/f934d130-c9fa-46bb-b677-88edf64bc623"
/>

<img width="508" height="325" alt="image"
src="https://github.com/user-attachments/assets/4c776922-a169-4543-b791-fcefc093fa7f"
/>

## Testing
- Not run locally in this environment.
2026-02-10 09:41:48 +00:00
a63b31931f Extract SecureHttpClientService into its own module (#17828)
## Summary

- **Extract `SecureHttpClientService`** from `tool` module into a
dedicated `core-modules/secure-http-client/` module with proper NestJS
module encapsulation
- **Fix module hygiene**: 12 modules that incorrectly listed
`SecureHttpClientService` as a direct provider now properly import
`SecureHttpClientModule`
- **Add structured logging** for outbound HTTP requests with
workspace/user context (for GuardDuty alert correlation)
- **Rename type files** to follow one-export-per-file convention
(`get-secure-axios-adapter.types.ts` ->
`secure-adapter-dependencies.type.ts`, new
`outbound-request-context.type.ts` / `outbound-request-source.type.ts`)

### Why

`SecureHttpClientService` is a cross-cutting concern (used by auth,
captcha, file upload, geo-map, telemetry, admin-panel, REST API, contact
creation, webhooks, and workflow tools) but was bundled inside the
`tool` module. Most consumers worked around this by listing it as a
direct provider instead of importing a module, which is fragile and not
idiomatic NestJS.

## Test plan

- [x] All 60 unit tests pass (`secure-http-client.service.spec.ts`,
`get-secure-axios-adapter.util.spec.ts`, `is-private-ip.util.spec.ts`)
- [x] Related module tests pass (admin-panel, contact-creation, tool)
- [x] `npx nx typecheck twenty-server` passes
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [x] Server compiles and bootstraps successfully


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
2026-02-10 09:16:51 +00:00
3c2aec1894 i18n - translations (#17830)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-10 10:32:49 +01:00
Baptiste DevessierandGitHub b250de216e Remove the code of old show pages (#17811)
Closes https://github.com/twentyhq/core-team-issues/issues/2213
2026-02-10 09:10:00 +00:00
2d42b0a726 - Added accomodation of mobile navigation bar in command menu (#17419)
Fix for #16743 

Added styles to accommodate for mobile navigation bar for better
visibility and better access of the command menu buttons.

## CommandMenuAskAIPage.tsx (Not shown in the issue)
### Before
<img width="1100" height="1792" alt="screenshot-2026-01-24_17-22-45"
src="https://github.com/user-attachments/assets/8a337ffe-9dfc-4e52-b744-622cc6989ae4"
/>

### After 
Since the component here already had some padding on it the mobile
navigation bar offset was lesser than it is in other places.
<img width="1092" height="1778" alt="screenshot-2026-01-24_17-28-33"
src="https://github.com/user-attachments/assets/00a217f1-d98e-4303-bdc1-df112d71a553"
/>


## CommandMenuWorkflowEditStep.tsx
### Before
As mentioned in the issue

### After
<img width="755" height="1061" alt="image"
src="https://github.com/user-attachments/assets/487d0b20-f544-4e85-99d5-eb5add4b2cb4"
/>

## CommandMenuWorkflowRunViewStep.tsx
### Before
As shown in the issue

### After
<img width="1102" height="1784" alt="screenshot-2026-01-24_17-19-31"
src="https://github.com/user-attachments/assets/a95773e3-5f61-46d0-93cb-e678e087a42f"
/>

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-02-09 23:48:45 +01:00
Abdullah.andGitHub 7e5e800f63 fix: remove axios stale entry by ensuring transitive version of axios is also latest (#17823)
Last upgrade PR left stale entries for transitive import of Axios. 

Raising this PR to fix and ensure Axios 1.13.5 is the de-facto in
yarn.lock for consistency.
2026-02-09 23:23:06 +01:00
Baptiste DevessierandGitHub 8c70a875f8 fix: set a margin even for empty side-column widgets (#17806)
| | Read | Edit |
|--------|--------|--------|
| **Before** | <img width="762" height="2144" alt="CleanShot 2026-02-09
at 16 07 06@2x"
src="https://github.com/user-attachments/assets/d93b5928-2c7b-438a-b16a-d17a434276fc"
/> | <img width="762" height="2144" alt="CleanShot 2026-02-09 at 16 07
01@2x"
src="https://github.com/user-attachments/assets/c5fb94db-947d-4832-9bc2-7942faa290eb"
/> |
| **After** | <img width="762" height="2144" alt="CleanShot 2026-02-09
at 16 06 33@2x"
src="https://github.com/user-attachments/assets/761352f3-cf18-4f41-98f0-fb0b4367f486"
/> | <img width="762" height="2144" alt="CleanShot 2026-02-09 at 16 06
41@2x"
src="https://github.com/user-attachments/assets/d1780671-1043-4087-860c-020343fcda87"
/> |


Closes https://github.com/twentyhq/core-team-issues/issues/2193
2026-02-09 22:59:00 +01:00
Abdullah.andGitHub a40612ef9e fix: axios related dependabot alerts (#17821)
Resolves [Dependabot Alert
436](https://github.com/twentyhq/twenty/security/dependabot/436),
[Dependabot Alert
437](https://github.com/twentyhq/twenty/security/dependabot/437) and
[Dependabot Alert
438](https://github.com/twentyhq/twenty/security/dependabot/438).
2026-02-09 22:56:34 +01:00
neo773andGitHub 1979e013e9 Google OAuth check real permissions before creating channels (#17714) 2026-02-09 22:49:03 +01:00
3747005fd5 i18n - translations (#17820)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-09 22:43:06 +01:00
neo773andGitHub 268cc40d80 Show accounts with pending message channel configuration (#17770) 2026-02-09 22:29:27 +01:00
MarieGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Félix Malfait
11fd1a41f3 [Fix] fix timelineActivities on notes and tasks (#17814)
[Fixes
sentry](https://twenty-v7.sentry.io/issues/7238708876/?environment=prod&environment=prod-eu&project=4507072499810304&query=Field%20metadata%20for%20field&referrer=issue-stream&sort=date):
_Field metadata for field "targetTargetPersonId" is missing in object
metadata timelineActivity_

Regression caused by migration of note and task targets - fields were
renamed from personId to targetPersonId, impacting the event names,
while we still expected them in their previous shape.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <[email protected]>
2026-02-09 20:47:43 +00:00
05eca08ad2 releasing RLS (#17809)
Co-authored-by: Charles Bochet <[email protected]>
2026-02-09 21:56:41 +01:00
Abdullah.andGitHub ef1464db73 Improve AI-chat design to match the one provided in Figma (#17816)
Fixed colors, gaps, component nesting, alignment to match Figma.
2026-02-09 18:28:25 +00:00
neo773andGitHub 5ccf18fdf5 Add handling for internal_failure in Gmail API error parser (#17718)
fixes TWENTY-SERVER-D3X
2026-02-09 18:02:09 +00:00
9e6f19d16e Fix RLS creation logic (#17815)
Fix after resolveEntityRelationUniversalIdentifiers introduction
```
FlatEntityMapsException [Error]: Could not find rowLevelPermissionPredicateGroup for given rowLevelPermissionPredicateGroupId
        at resolveEntityRelationUniversalIdentifiers
```

- Creating util for RLS flat entity creation to be aligned with other
entities
- Progressively update the flat entity maps as each new group is built,
following the same optimistic pattern used by
computeUniversalFlatEntityMapsFromTo in the validate build and run. The
updated maps are now passed to computePredicateOperations so predicates
can also resolve references to the newly created groups.
- Adding more coverage

Co-authored-by: Charles Bochet <[email protected]>
2026-02-09 19:19:23 +01:00
Charles BochetandGitHub 987ed845ac Release Twenty SDK 0.5.0 (#17818)
As per title + create-twenty-app
2026-02-09 19:16:24 +01:00
f51291704d [FRONT COMPONENTS] Retrieve the front components from the backend (#17813)
- Pass the auth token to worker
- Fetch the file from the rest API with the auth token
- Create the blob url
- Update the stories to pass a fake token which will be ignored

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-02-09 19:10:29 +01:00
Charles BochetandGitHub 37b9a55382 Migrate metrics to prometheus (#17810)
<img width="1316" height="611" alt="image"
src="https://github.com/user-attachments/assets/277a63ed-2a8b-41ff-be78-281de8891579"
/>
2026-02-09 19:09:13 +01:00
aa7973e5b8 i18n - translations (#17817)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-09 19:08:48 +01:00
8c951d3623 Migrate Views-xxx Index Field Object Skill to be fully universal ( all actions and metadata runner and builder ) + all metadata update actions runner (#17687)
# What this PR does
Overall naming `universal` versus `flat` is not always the most updated
and so on
Will make a big cleaning tour after I've finished the whole migration
Migrating all `view` and ( filter fields etc ) `field` `object` `index`
to the universal pattern on all `services`, `builder` and `runner`
levels

## Universal and flat optimistic tooling

`addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow`
and its delete counterpart maintain the consistency of
`UniversalFlatEntityMaps` when an entity is created or removed. Beyond
inserting/removing the entity from its own maps, they walk through
`ALL_UNIVERSAL_METADATA_RELATIONS` to update the **aggregator arrays**
on related parent entities — the add appends the new entity's
`universalIdentifier` to the parent's aggregator (e.g. a new viewField's
identifier gets appended to its parent view's
`viewFieldUniversalIdentifiers`), and the delete filters it out. This
keeps the maps in sync so that diff computations and relation lookups
remain accurate throughout the migration building process.

## ALL_UNIVERSAL_METADATA_RELATIONS
`ALL_UNIVERSAL_METADATA_RELATIONS` is the universal counterpart of
`ALL_METADATA_RELATIONS`. It maps each metadata entity to its
many-to-one and one-to-many relations using universal foreign keys
(`*UniversalIdentifier`) instead of database IDs (`*Id`). This allows
migration actions to reference related entities in a workspace-agnostic
way. Relations that are workspace-specific (e.g. `workspace`,
`dataSource`, `userWorkspace`) are set to `null` and skipped during
resolution.

## `workspaceMigrationCreateIdEnrichment`
Reserved to API metadata ( will be able to validate at app installation
lvl )
- Workspace migration `create` actions now carry an optional `id` (and
`fieldIdByUniversalIdentifier` for object/field actions) so that
caller-provided IDs flow through the entire build-validate-run pipeline.
- New `enrichCreateWorkspaceMigrationActionsWithIds` utility resolves
`universalIdentifier → id` mappings after the builder runs and injects
them into the migration actions before the runner persists entities.
- Runner action handlers use the provided IDs instead of generating new
UUIDs, enabling deterministic entity creation for synchronization
workflows.


## `resolveUniversalUpdateRelationIdentifiersToIds`
`resolveUniversalUpdateRelationIdentifiersToIds` converts universal
identifiers (workspace-agnostic, stable keys) in a migration update
payload into concrete database UUIDs, so the update can be applied to a
specific workspace.

It iterates over the many-to-one relations defined in
`ALL_UNIVERSAL_METADATA_RELATIONS` for the given entity type, replaces
each `*UniversalIdentifier` property with its corresponding `*Id` by
looking up the target entity in `allFlatEntityMaps`, and throws if a
non-null identifier can't be resolved.

Used by all `update` action handlers in
`transpileUniversalActionToFlatAction`, avoiding duplicated resolution
logic across handlers.

## What this PR does not
- Migrating twenty-standard declaration to universal
- Migrating all the inputs transpilers to universal
- Migrating all metadata to be fully universal ( we still need to
de-scope the type of all of them and refactor their validator very close
)

---------

Co-authored-by: Charles Bochet <[email protected]>
2026-02-09 19:02:38 +01:00
2a76e1791e Allow AI to update code steps (#17761)
https://github.com/user-attachments/assets/35ac5c23-4d5c-4c86-8233-7b58fbeb5a27

- add tool
- move resolver code into a service

---------

Co-authored-by: Félix Malfait <[email protected]>
2026-02-09 15:40:44 +00:00
Weiko 4ae375308f Revert "Releasing RLS"
This reverts commit c01853c349.
2026-02-09 16:52:58 +01:00
Weiko c01853c349 Releasing RLS 2026-02-09 16:52:12 +01:00
MarieandGitHub 21b2b65dbe [Fix] fix relations in apps (#17791)
When syncing an app with relation multiple times, it would fail because
the relation fields could not be identified due to universalIdentifier
being overwritten at field cretion
2026-02-09 15:22:14 +00:00
Raphaël BosiandGitHub c6d04ccced [FRONT COMPONENTS] Serialize events through the worker boundary (#17767)
- Introduces a SerializedEventData type that captures only serializable
properties from DOM/React events
- Updates `wrapEventHandler` in the host component registry to serialize
native events via serializeEvent() before passing them across the worker
boundary via postMessage, avoiding circular references and non-cloneable
DOM nodes
- Updates generated remote element event signatures to use
RemoteEvent<SerializedEventData> instead of bare RemoteEvent, giving
front-component authors typed access to event details
2026-02-09 16:22:54 +01:00
c18726d712 Fix captcha validation failing due to missing URL in secure axios adapter (#17807)
## Summary

- Fixes login failing with `Error: URL is required` when captcha (Google
reCAPTCHA or Turnstile) is enabled
- The secure axios adapter (`getSecureAxiosAdapter`) checked
`config.url` before `baseURL` resolution. In Axios, `baseURL + url`
combination happens inside the default HTTP adapter, not before custom
adapters are called. When captcha drivers configure a `baseURL` and call
`.post('', data)`, the empty string url was incorrectly treated as
missing.
- The adapter now resolves `baseURL` + `url` itself before validation,
matching the default Axios HTTP adapter behavior

## Test plan

- [x] Existing unit tests (23 tests) all pass
- [ ] Verify login works with captcha enabled (`CAPTCHA_DRIVER` set to
`google-recaptcha` or `turnstile`)


Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-09 16:20:41 +01:00
WeikoandGitHub 96bc3594a3 Serve frontend components (#17798)
## Context
Ability to serve frontend component

See:
```typescript
curl -i 'http://localhost:3000/rest/front-components/35063b3f-bc4c-4358-8966-7762677802a3' \
--header 'Authorization: Bearer eyJhb...'
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/javascript
Date: Mon, 09 Feb 2026 10:28:17 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

// react-globals:react/jsx-runtime
var jsx = globalThis.jsx;
var jsxs = globalThis.jsxs;
var Fragment = globalThis.React.Fragment;

// src/front-components/test.tsx
var RemoteComponents = globalThis.RemoteComponents;
var Component = () => {
  return /* @__PURE__ */ jsxs(RemoteComponents.HtmlDiv, { style: { padding: "20px", fontFamily: "sans-serif" }, children: [
    /* @__PURE__ */ jsx(RemoteComponents.HtmlH1, { children: "My new component!" }),
    /* @__PURE__ */ jsx(RemoteComponents.HtmlP, { children: "This is your front component: test" })
  ] });
};
var test_default = globalThis.jsx(Component, {});
export {
  test_default as default
};
//# sourceMappingURL=test.mjs.map
```

readFile_v2 returns a Node.js Stream object (Readable). Here we are
using stream pipeline which connects the readable stream (file) to the
writable stream (HTTP response) which efficiently streams the file
content directly to the HTTP response without loading the entire file
into memory. (in chunks, handling backpressure and closing the
connection when the file is fully sent)
2026-02-09 14:52:14 +00:00
Baptiste DevessierandGitHub 617f634b6d Use backend types in Record Page Layouts' frontend (#17794) 2026-02-09 14:36:02 +00:00
8e977912e2 i18n - translations (#17804)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-09 15:44:40 +01:00
4cdb7d61b2 Redesign AI chat and add pre-existing prompts. (#17787)
Redesigned AI-chat based on the following provided design.

<p align="center">
<img
src="https://github.com/user-attachments/assets/f10ebbd2-9ee9-402f-b246-6e8f8cedbd53"
width="225" />
</p>

---------

Co-authored-by: Félix Malfait <[email protected]>
2026-02-09 15:29:32 +01:00
Raphaël BosiandGitHub 7fbd50e5a0 [FRONT COMPONENTS] Create Icon component in twenty ui (#17797)
This will be used in the remote dom. We need a component which can take
a icon name as a parameter and return an icon component.
2026-02-09 13:57:22 +00:00
Abdullah.andGitHub 0b4cebfe0e Resolve tar related dependabot alerts in the application-layer. (#17801)
Resolves [Dependabot Alert
424](https://github.com/twentyhq/twenty/security/dependabot/424),
[Dependabot Alert
425](https://github.com/twentyhq/twenty/security/dependabot/425) and
[Dependabot Alert
426](https://github.com/twentyhq/twenty/security/dependabot/426).
2026-02-09 14:41:26 +01:00
Charles BochetandGitHub d7c28d6455 Fix file constraint migration (#17796)
## Summary

- Wrap `AddFileEntityUniqueConstraint` migration in a SAVEPOINT
try-catch so it doesn't break when duplicate `(workspaceId,
applicationId, path)` rows exist in `core.file`
- Extend `DeleteFileRecordsCommand` upgrade command to add the unique
constraint after cleaning up file records
2026-02-09 14:40:35 +01:00
Raphaël BosiandGitHub 2b29918bf8 [FRONT COMPONENTS] Navigate from the remote (#17762)
## PR Description

This PR:
- Introduces a `FrontComponentHostCommunicationApi`, which allows us to
pass functions to be executed from the worker
- Exposes a navigate function from the host to the front component
remote workers, enabling SDK components to trigger in-app navigation
- Gets rid of `useSyncExternalStore` and makes the execution context
reactive without it
- Refactors and improves the `esbuild` plugin system

## Video


https://github.com/user-attachments/assets/7b26a1c2-f85f-4898-a71d-f60c70e61711
2026-02-09 14:38:35 +01:00
2106f46e9e i18n - translations (#17803)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-09 14:35:41 +01:00
MarieandGitHub 1edce5088c Flush cache before and after upgrade for self-hosts (#17800)
as per title
2026-02-09 14:35:13 +01:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
3216b634a3 feat: improve AI chat - system prompt, tool output, context window display (#17769)
⚠️ **AI-generated PR — not ready for review** ⚠️

cc @FelixMalfait

---

## Changes

### System prompt improvements
- Explicit skill-before-tools workflow to prevent the model from calling
tools without loading the matching skill first
- Data efficiency guidance (default small limits, use filters)
- Pluralized `load_skill` → `load_skills` for consistency with
`load_tools`

### Token usage reduction
- Output serialization layer: strips null/undefined/empty values from
tool results
- Lowered default `find_*` limit from 100 → 10, max from 1000 → 100

### System object tool generation
- System objects (calendar events, messages, etc.) now generate AI tools
- Only workflow-related and favorite-related objects are excluded

### Context window display fix
- **Bug**: UI compared cumulative tokens (sum of all turns) against
single-request context window → showed 100% after a few turns
- **Fix**: Track `conversationSize` (last step's `inputTokens`) which
represents the actual conversation history size sent to the model
- New `conversationSize` column on thread entity with migration

### Workspace AI instructions
- Support for custom workspace-level AI instructions

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-09 14:26:02 +01:00
Abdullah.andGitHub 6c7c389785 fix: webpack related dependabot alerts (#17792)
Resolves [Dependabot Alert
422](https://github.com/twentyhq/twenty/security/dependabot/422) and
[Dependabot Alert
423](https://github.com/twentyhq/twenty/security/dependabot/423).
2026-02-09 13:12:05 +01:00
martmullandGitHub 9162685b2e Reorganize logic function files (#17766)
reorganize according to

<img width="1243" height="725" alt="Pasted Graphic"
src="https://github.com/user-attachments/assets/ba65dd10-8eec-4b13-ad49-9726edd3b79c"
/>

Not working yet
2026-02-09 12:36:39 +01:00
bf4c348c8b i18n - translations (#17790)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-09 11:18:24 +01:00
Félix MalfaitGitHubCursorclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Etienne
ece265c6e4 Harden local file storage driver path resolution (#17783)
## Summary

- Normalize all file paths with `path.resolve` instead of `join` to
properly handle `..` segments in file path inputs
- Add `assertPathIsWithinStorage` guard on all write, delete, move,
copy, and existence-check operations
- Introduce `ACCESS_DENIED` exception code with i18n-ready user-friendly
message
- Read path already had realpath-based validation; updated its error
code to `ACCESS_DENIED` for consistency

## Test plan

- [x] Typecheck passes
- [x] Lint passes
- [x] Manual: verify file upload/download still works with valid paths
- [x] Manual: verify `../` in file paths is rejected with ACCESS_DENIED


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Etienne <[email protected]>
2026-02-09 09:54:10 +00:00
15f09736b2 Fix importing people with mixed-case domain URL failing to match company (#17774)
Fix #17711 
### Reproduce steps
1. import this xlsx file
[test-import.xlsx](https://github.com/user-attachments/files/25156642/test-import.xlsx)

2. import company worksheet at first and then import people worksheet
3. you will see an error <img width="324" height="101"
alt="Snipaste_2026-02-07_19-22-04"
src="https://github.com/user-attachments/assets/bf2703da-a57a-4795-805b-6ddcc689c621"
/>

And I found neither the frontend nor the backend applies lowercase
normalization to the query value. Although the standard UI input always
displays company links in lowercase, user might mixed the case in their
xlsx/csv bulk import. So I did a simple check in frontend.

### Additional findings:
1. Email has the same case sensitivity issue when opportunities import.
You can test same as in test-import.xlsx file (I created a opportunities
worksheet. It will have same issue: <img width="330" height="101"
alt="email error"
src="https://github.com/user-attachments/assets/db36b19b-2abe-4c61-a661-f8d1eb357a5e"
/>

2. API also has: I also tested via the GraphQL API Playground and
confirmed that createOnePerson with a mixed-case URL fails to find an
existing company. <img width="1419" height="513"
alt="Snipaste_2026-02-07_23-33-56"
src="https://github.com/user-attachments/assets/c189f1fd-9793-42d5-a1a9-616cffd2592e"
/>

### Approach:
1. Only change it in frontend, and I will add email check later; (my
current commit)
2. Or backend: Normalize values in computeUniqueConstraintCondition in
twenty-server/src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util.ts
(which handles connect.where queries, and this will cover xlsx import
and api import: createone, createmany, updatemany, updateone. And much
better for future extension.

(Personally, I'd prefer the backend approach. But I'd love to hear your
thoughts on which approach you'd prefer, and whether my analysis is on
the right track.

---------

Co-authored-by: Etienne <[email protected]>
2026-02-09 09:38:09 +00:00
Félix MalfaitGitHubCursorclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
bade5289b6 Fix Cloudflare webhook guard validation logic (#17780)
## Summary

- Restructures the `CloudflareSecretMatchGuard` to properly validate the
`cf-webhook-auth` header before performing the comparison — checks for
missing, empty, or mismatched-length values first
- Removes `@ts-expect-error` workarounds with explicit typed header
access
- Expands test coverage with additional edge cases (missing header,
wrong value, empty string, length mismatch)

## Test plan

- [x] Unit tests pass (6 tests covering matching, missing config,
missing header, wrong value, empty string, length mismatch)


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-09 08:23:57 +00:00
497230a052 i18n - translations (#17789)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-09 00:22:32 +01:00
WeikoandGitHub 9aa63f7ddc Add sync front component (#17748)
## Context
Allow twenty apps to sync front components
2026-02-08 23:00:23 +00:00
Félix MalfaitGitHubCursorclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
40d7e740ef Add token type validation and remove dead code in JWT verification (#17784)
## Summary

- Removes unreachable dead code in `verifyJwtToken` — a legacy API key
verification block where the condition (`!payload.type && type ===
API_KEY`) was logically impossible. Also removes the now-unused
`isLegacyApiKey` parameter and `generateAppSecretLegacy` method.
- Adds explicit token type validation after JWT decode in
`verifyLoginToken`, `verifyRefreshToken`, and `verifyTransientToken`.
Each function now rejects tokens whose `type` field doesn't match what's
expected (defense-in-depth — the HMAC secret already binds the type, but
this makes the contract explicit).

## Test plan

- [x] Updated existing specs for login-token, refresh-token, renew-token
services — all 16 tests passing
- [x] Lint clean

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-08 19:44:42 +00:00
7402edb887 Remove unused GraphQL throttler plugin and graphql-rate-limit dependency (#17785)
## Summary

- Deletes `use-throttler.ts` — an envelop plugin that was never
registered in the GraphQL config plugin chain (dead code since
introduction)
- Removes the `graphql-rate-limit` dependency from `package.json` (only
consumer was the deleted file)

API rate limiting continues to work via `ThrottlerService` in the query
runner layer (for API key auth). Global coverage should be handled at
the infrastructure level (Cloudflare).

## Test plan

- [x] Confirmed no imports of `useThrottler`, `ThrottlerPluginOptions`,
or `UnauthenticatedError` from this file exist anywhere in the codebase
- [x] `graphql-rate-limit` has no other consumers

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-08 19:52:59 +01:00
Félix MalfaitGitHubCursorclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
d7132b35d3 Centralize outbound HTTP requests through SecureHttpClientService (#17779)
## Summary

- Migrates all direct `axios` and `@nestjs/axios` `HttpService` usages
across the server to go through `SecureHttpClientService`, which
conditionally applies SSRF protection based on the
`OUTBOUND_HTTP_SAFE_MODE_ENABLED` config flag
- `SecureHttpClientService.getHttpClient()` now accepts optional
`AxiosRequestConfig` (e.g., `baseURL`) so callers can configure their
client while still getting protection
- Adds `getInternalHttpClient()` for trusted same-server requests (e.g.,
REST-to-GraphQL proxy, code-interpreter downloading internal files)
- Renames `getSecureAdapter` to `getSecureAxiosAdapter` for clarity
- Captcha drivers now receive a pre-configured `AxiosInstance` from the
module factory instead of creating their own

## Migrated services

| Service | Previous | Risk level |
|---------|----------|-----------|
| `file-upload.service` | `HttpService` | High (user-provided image
URLs) |
| `code-interpreter-tool` | `HttpService` + direct adapter | High
(user-provided file URLs) |
| `search-help-center-tool` | `axios.post()` | Low (hardcoded endpoints)
|
| `http-tool` | Already migrated | High (user-provided URLs) |
| `admin-panel.service` | `axios.get()` | Low (Docker Hub API) |
| `sign-in-up.service` | `HttpService` | Medium (logo URL validation) |
| `google-apis-scopes` | `HttpService` | Low (Google API) |
| `geo-map.service` | `HttpService` | Low (Google Maps API) |
| `telemetry.service` | `HttpService` | Low (telemetry endpoint) |
| `rest-api.service` | `HttpService` | Internal (uses
`getInternalHttpClient`) |
| `create-company.service` | `axios.create()` | Low (Twenty companies
API) |
| `google-recaptcha.driver` | `axios.create()` | Low (Google reCAPTCHA)
|
| `turnstile.driver` | `axios.create()` | Low (Cloudflare Turnstile) |

## Test plan

- [x] `npx nx typecheck twenty-server` passes
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [x] Admin panel unit tests pass
- [x] Secure adapter unit tests pass

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-08 19:52:35 +01:00
bc6268bb29 Rename REFRESH_TOKEN_COOL_DOWN to REFRESH_TOKEN_REUSE_GRACE_PERIOD and anchor the grace window (#17782)
## Summary

- Renames `REFRESH_TOKEN_COOL_DOWN` to
`REFRESH_TOKEN_REUSE_GRACE_PERIOD` — the old name was misleading and
suggested a security mechanism rather than what it actually is: a grace
period for concurrent refresh token use (e.g. two browser tabs
refreshing simultaneously).
- Makes the token revocation in `renew-token.service.ts` conditional
(`revokedAt: IsNull()`), so if the token was already revoked by a
concurrent request, the original `revokedAt` timestamp is preserved and
the grace window stays anchored.
- Updates comments and config description to clarify intent.

## Test plan

- [x] Existing unit tests updated and passing
(`refresh-token.service.spec.ts`, `renew-token.service.spec.ts`)
- [x] Lint clean

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <[email protected]>
2026-02-08 18:15:37 +01:00
Félix MalfaitGitHubCursorclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
c44d61f324 Validate timezone input in group-by date queries (#17777)
## Summary

- Validates that the timezone parameter in group-by date expressions is
a recognized IANA timezone
- Adds SQL string literal escaping as a defense-in-depth measure for the
timezone value interpolated into SQL expressions
- Moves the `IANA_TIME_ZONES` constant to `twenty-shared` so it can be
reused across frontend and server packages
- Adds `INVALID_TIMEZONE` error code mapped to 400 Bad Request in both
GraphQL and REST API exception handlers

## Test plan

- [x] Unit tests for `validateIanaTimeZone` (valid IANA zones, fixed
offsets, rejects invalid strings)
- [x] Unit tests for `escapeSqlStringLiteral`
- [x] Integration tests for `getGroupByExpression` covering timezone
validation and granularity handling


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-02-08 16:06:19 +00:00
WeikoandGitHub cc86b9acae Fix record page layout BE (#17758)
## Context
- Add missing conditional display
- Set default tab as null for most of the page layout, letting the FE
handle that
- Fix duplicate "Note" widget in both task and note pages
- Simplify typing, removing redundant layoutName
2026-02-06 18:34:35 +00:00
30cbf0e40e i18n - translations (#17768)
Created by Github action

---------

Co-authored-by: github-actions <[email protected]>
2026-02-06 18:27:52 +01:00
Baptiste DevessierandGitHub 4acca5b10d Store record page layouts in a global state (#17759) 2026-02-06 17:12:00 +00:00
Paul RastoinandGitHub 3dc5b162c7 Spread in parent and requires FlatEntity.__universal (#17753)
# Introduction
Requiring the spreaded `__universal` record that aggregates all the
universal identifier ( relations fk and aggregators ) of an entity to
its root

It's blockin for https://github.com/twentyhq/twenty/pull/17687 to be
finalized because if we don't we would have to migrated all related
entities at once in order for them to always have the universal
properties

## `resolveEntityRelationUniversalIdentifiers`
Introduced `resolveEntityRelationUniversalIdentifiers` a centralized
utility that resolves foreign key IDs to universal identifiers using
ALL_METADATA_RELATIONS metadata. It provides strict typing for both
input (foreign keys) and output (universal identifiers), with
nullability dynamically inferred from entity relation types.
Strictly and dynamically typed for both output and input

To do so added a new type and const/runtime grain to
ALL_METADATA_RELATIONS `isNullable`to many-to-one entries, derived from
the entity relation property types. And fixed incorrectly typed typeorm
entities

### Usage
```ts
  const {
    availabilityObjectMetadataUniversalIdentifier,
    frontComponentUniversalIdentifier,
  } = resolveEntityRelationUniversalIdentifiers({
    metadataName: 'commandMenuItem',
    foreignKeyValues: {
      availabilityObjectMetadataId:
        createCommandMenuItemInput.availabilityObjectMetadataId,
      frontComponentId: createCommandMenuItemInput.frontComponentId,
    },
    flatEntityMaps: { flatObjectMetadataMaps, flatFrontComponentMaps },
  });
```
2026-02-06 17:02:02 +00:00
Thomas TrompetteandGitHub a494a7a902 Fix iterator creation (#17763)
On iterator creation, we get an error "Bad configuration". Looks like a
race condition with generation that happens before apollo cache gets
updated. Adding defensive checks
2026-02-06 15:25:09 +00:00
f6beb06364 Migrate favorites to navigation menu items (1.17 upgrade) (#17477)
**What this PR does:**
- Migrates existing `Favorite` and `FavoriteFolder` entities to the new
`NavigationMenuItem` structure
- Preserves user-level vs workspace-level ownership
- Preserves folder structure, positions, and relationships
- Handles both view-based favorites (linked to views) and record-based
favorites (linked to records)
- Soft-deletes original favorites and folders after successful migration
- Enables the `IS_NAVIGATION_MENU_ITEM_ENABLED` feature flag
post-migration
- Skips migration if the feature flag is already enabled
- Idempotent: checks for existing navigation menu items to prevent
duplicates

**Migration flow:**
1. Migrate favorite folders first (creates folder mapping)
2. Migrate favorites
3. Soft-delete migrated favorites and folders
4. Enable feature flag

**What's next:**
- After all workspaces are migrated and the navigation menu item feature
is fully rolled out, we can:
- Remove the old `Favorite` and `FavoriteFolder` entities and related
code
- Remove the feature flag check and make navigation menu items the
default
  - Clean up any deprecated favorites-related code paths in the frontend

---------

Co-authored-by: Etienne <[email protected]>
2026-02-06 13:42:20 +00:00
7074d9ce1b Fix CSV preview duplicate key warning (#10920) (#17754)
Fix issue #10920
<img width="1264" height="630" alt="image"
src="https://github.com/user-attachments/assets/d7a00e4f-85cb-49e1-aa38-4c807f1e1b69"
/>


The root cause is that `@cyntler/react-doc-viewer`'s CSV renderer uses
**cell values as React keys** instead of indices.

### There are two approaches:
1. **patch-package** — Directly fix the key usage in the library's
source code and pull a request to the author of
`@cyntler/react-doc-viewer` to fix it.

<img width="880" height="469" alt="image"
src="https://github.com/user-attachments/assets/819e5b6a-21ae-4333-87f5-3b7f6d7e2738"
/>

<img width="1753" height="568" alt="image"
src="https://github.com/user-attachments/assets/c8a0ee49-9bf4-4002-aad2-65914ac10254"
/>



2. Custom CSV renderer (My current code commit)

### Changes
- `fetchCsvPreview.ts` — Fetches CSV and parses it into headers and rows
- `DocumentViewer.tsx` — Renders CSV with a custom table instead of
passing it to DocViewer

---------

Co-authored-by: Etienne <[email protected]>
2026-02-06 13:35:47 +00:00
WeikoandGitHub b3c95744ef Fix twenty-emails build (#17760)
## Context
I've seen errors in twenty-emails build where I18n from @lingui/core was
resolved to two different declaration files
```typescript
src/components/BaseEmail.tsx:20:19 - error TS2719: Type 'import("/Users/weiko/Projects/twenty/node_modules/@lingui/core/dist/index").I18n' is not assignable to type 'import("/Users/weiko/Projects/twenty/node_modules/@lingui/core/dist/index").I18n'. Two different types with this name exist, but they are unrelated.
  Types have separate declarations of a private property '_locale'.

20     <I18nProvider i18n={i18nInstance}>
                     ~~~~

  node_modules/@lingui/react/dist/shared/react.b2b749a9.d.ts:42:5
    42     i18n: I18n;
           ~~~~
    The expected type comes from property 'i18n' which is declared here on type 'IntrinsicAttributes & Omit<I18nContext, "_"> & { children?: ReactNode; }'
 ```
Seems to be related to https://github.com/twentyhq/twenty/pull/17380
The tsconfig simplification changed how vite plugin resolves types during build. With the inherited moduleResolution: "node", the plugin and source code resolved @lingui/react's types differently, creating two incompatible I18n types from the same package.

## Fix
Add moduleResolution: "bundler" to packages/twenty-emails/tsconfig.json, aligning with twenty-front, twenty-ui, twenty-shared should fix the issue
2026-02-06 11:17:11 +00:00
Lakshay ManchandaandGitHub 10de51fcbd - fixed coloring of item type tag in dark mode (#17745)
Fixed issue #17694 

<img width="963" height="1021" alt="image"
src="https://github.com/user-attachments/assets/a28948a1-126d-4f3c-8a53-c39adbb7f52d"
/>
2026-02-06 10:28:04 +00:00
1724 changed files with 62185 additions and 33959 deletions
+45 -58
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test]
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
@@ -47,67 +47,54 @@ jobs:
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
# TODO uncomment when syncApplication resolver is fixed
# sdk-e2e-integration-test:
# timeout-minutes: 30
# runs-on: ubuntu-latest-8-cores
# needs: [changed-files-check, sdk-test]
# strategy:
# matrix:
# task: [test:integration, test:e2e]
# if: needs.changed-files-check.outputs.any_changed == 'true'
# services:
# postgres:
# image: twentycrm/twenty-postgres-spilo
# env:
# PGUSER_SUPERUSER: postgres
# PGPASSWORD_SUPERUSER: postgres
# ALLOW_NOSSL: 'true'
# SPILO_PROVIDER: 'local'
# ports:
# - 5432:5432
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# redis:
# image: redis
# ports:
# - 6379:6379
# env:
# NODE_ENV: test
# steps:
# - name: Fetch custom Github Actions and base branch history
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - name: Server / Append billing config to .env.test
# working-directory: packages/twenty-server
# run: |
# echo "" >> .env.test
# echo "IS_BILLING_ENABLED=true" >> .env.test
# echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
# echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
# echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
# echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
# - name: Server / Create Test DB
# run: |
# PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
# - name: SDK / Run ${{ matrix.task }} Tests
# uses: ./.github/actions/nx-affected
# with:
# tag: scope:sdk
# tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: SDK / Run e2e Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test]
# TODO uncomment when syncApplication resolver is fixed
# needs: [changed-files-check, sdk-test, sdk-e2e-integration-test]
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.4.8",
"version": "0.5.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -70,7 +70,7 @@ describe('copyBaseApplicationProject', () => {
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.dependencies['twenty-sdk']).toBe('0.4.8');
expect(packageJson.dependencies['twenty-sdk']).toBe('0.5.0');
expect(packageJson.scripts['app:dev']).toBe('twenty app:dev');
});
@@ -189,7 +189,6 @@ const createDefaultFunction = async ({
fileName: string;
}) => {
const universalIdentifier = v4();
const triggerUniversalIdentifier = v4();
const content = `import { defineLogicFunction } from 'twenty-sdk';
@@ -204,15 +203,11 @@ export default defineLogicFunction({
description: 'A simple logic function',
timeoutSeconds: 5,
handler,
triggers: [
{
universalIdentifier: '${triggerUniversalIdentifier}',
type: 'route',
path: '/hello-world-logic-function',
httpMethod: 'GET',
isAuthRequired: false,
},
],
httpRouteTriggerSettings: {
path: '/hello-world-logic-function',
httpMethod: 'GET',
isAuthRequired: false,
},
});
`;
@@ -282,7 +277,7 @@ const createPackageJson = async ({
'lint:fix': 'eslint --fix',
},
dependencies: {
'twenty-sdk': '0.4.8',
'twenty-sdk': '0.5.0',
},
devDependencies: {
typescript: '^5.9.3',
@@ -17,7 +17,10 @@ setup_and_migrate_db() {
yarn database:migrate:prod
fi
yarn command:prod cache:flush
yarn command:prod upgrade
yarn command:prod cache:flush
echo "Successfully migrated DB!"
}
@@ -7,6 +7,9 @@ test('Create workflow', async ({ page }) => {
await page.goto(process.env.LINK);
const workflowsFolder = page.getByRole('button', { name: 'Workflows' });
await workflowsFolder.click();
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
await workflowsLink.click();
+1
View File
@@ -2,6 +2,7 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
+3 -1
View File
@@ -21,7 +21,6 @@ module.exports = {
'./src/modules/logic-functions/graphql/**/*.{ts,tsx}',
'./src/modules/databases/graphql/**/*.{ts,tsx}',
'./src/modules/workflow/**/graphql/**/*.{ts,tsx}',
'./src/modules/analytics/graphql/**/*.{ts,tsx}',
'./src/modules/object-metadata/graphql/**/*.{ts,tsx}',
'./src/modules/navigation-menu-item/graphql/**/*.{ts,tsx}',
@@ -32,6 +31,9 @@ module.exports = {
'./src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}',
'./src/modules/dashboards/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
'./src/modules/marketplace/graphql/**/*.{ts,tsx}',
'!./src/**/*.test.{ts,tsx}',
'!./src/**/*.stories.{ts,tsx}',
'!./src/**/__mocks__/*.ts',
+3 -15
View File
@@ -5,23 +5,11 @@ module.exports = {
(process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') +
'/graphql',
documents: [
'./src/modules/activities/graphql/**/*.{ts,tsx}',
'./src/modules/companies/graphql/**/*.{ts,tsx}',
'./src/modules/people/graphql/**/*.{ts,tsx}',
'./src/modules/opportunities/graphql/**/*.{ts,tsx}',
'./src/modules/workflow/**/graphql/**/*.{ts,tsx}',
'./src/modules/activities/emails/graphql/**/*.{ts,tsx}',
'./src/modules/activities/calendar/graphql/**/*.{ts,tsx}',
'./src/modules/search/graphql/**/*.{ts,tsx}',
'./src/modules/views/graphql/**/*.{ts,tsx}',
'./src/modules/favorites/graphql/**/*.{ts,tsx}',
'./src/modules/spreadsheet-import/graphql/**/*.{ts,tsx}',
'./src/modules/command-menu/graphql/**/*.{ts,tsx}',
'./src/modules/marketplace/graphql/**/*.{ts,tsx}',
'./src/modules/prefetch/graphql/**/*.{ts,tsx}',
'./src/modules/subscription/graphql/**/*.{ts,tsx}',
'./src/modules/dashboards/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
'!./src/**/*.test.{ts,tsx}',
'!./src/**/*.stories.{ts,tsx}',
+3 -3
View File
@@ -62,9 +62,9 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 50,
lines: 48.9,
functions: 40.9,
statements: 49.5,
lines: 48,
functions: 40,
},
},
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { type AppPath } from 'twenty-shared/types';
import { type AppPath, type NavigateOptions } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
export const useNavigateApp = () => {
@@ -9,10 +9,7 @@ export const useNavigateApp = () => {
to: T,
params?: Parameters<typeof getAppPath<T>>[1],
queryParams?: Record<string, any>,
options?: {
replace?: boolean;
state?: any;
},
options?: NavigateOptions,
) => {
const path = getAppPath(to, params, queryParams);
return navigate(path, options);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { type ConnectionParameters } from '~/generated/graphql';
import { type ConnectionParameters } from '~/generated-metadata/graphql';
export type ImapSmtpCaldavAccount = {
IMAP?: ConnectionParameters;
@@ -1,6 +1,9 @@
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/const/previewable-extensions.const';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview';
import {
type CsvPreviewData,
fetchCsvPreview,
} from '@/activities/files/utils/fetchCsvPreview';
import { getFileType } from '@/activities/files/utils/getFileType';
import DocViewer, { DocViewerRenderers } from '@cyntler/react-doc-viewer';
import '@cyntler/react-doc-viewer/dist/index.css';
@@ -79,6 +82,39 @@ const StyledTitle = styled.div`
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledCsvTable = styled.table`
border-collapse: collapse;
font-size: ${({ theme }) => theme.font.size.sm};
text-align: left;
width: 100%;
th {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.tertiary};
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(8)};
padding: 0 ${({ theme }) => theme.spacing(2)};
}
td {
color: ${({ theme }) => theme.font.color.secondary};
height: ${({ theme }) => theme.spacing(8)};
max-width: 200px;
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
text-overflow: ellipsis;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
}
tbody tr:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
type DocumentViewerProps = {
documentName: string;
documentUrl: string;
@@ -165,7 +201,9 @@ export const DocumentViewer = ({
}: DocumentViewerProps) => {
const { t } = useLingui();
const theme = useTheme();
const [csvPreview, setCsvPreview] = useState<string | undefined>(undefined);
const [csvPreview, setCsvPreview] = useState<CsvPreviewData | undefined>(
undefined,
);
const { extension } = getFileNameAndExtension(documentName);
const fileExtension = isDefined(documentExtension)
@@ -175,15 +213,11 @@ export const DocumentViewer = ({
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
const isMsOfficeFile = MS_OFFICE_EXTENSIONS.includes(fileExtension);
const mimeType = PREVIEWABLE_EXTENSIONS.includes(fileExtension)
? MIME_TYPE_MAPPING[fileExtension]
: undefined;
const mimeType = isPreviewable ? MIME_TYPE_MAPPING[fileExtension] : undefined;
useEffect(() => {
if (fileExtension === 'csv') {
fetchCsvPreview(documentUrl).then((content) => {
setCsvPreview(content);
});
fetchCsvPreview(documentUrl).then(setCsvPreview);
}
}, [documentUrl, fileExtension]);
@@ -218,12 +252,37 @@ export const DocumentViewer = ({
);
}
if (fileExtension === 'csv' && !isDefined(csvPreview))
if (fileExtension === 'csv') {
if (!isDefined(csvPreview)) {
return (
<StyledDocumentViewerContainer>
<Trans>Loading csv ... </Trans>
</StyledDocumentViewerContainer>
);
}
return (
<StyledDocumentViewerContainer>
<Trans>Loading csv ... </Trans>
<StyledDocumentViewerContainer style={{ background: 'transparent' }}>
<StyledCsvTable>
<thead>
<tr>
{csvPreview.headers.map((header, columnIndex) => (
<th key={columnIndex}>{header}</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
))}
</tbody>
</StyledCsvTable>
</StyledDocumentViewerContainer>
);
}
if (isMsOfficeFile && isPrivateUrl(documentUrl)) {
return (
@@ -251,12 +310,7 @@ export const DocumentViewer = ({
<DocViewer
documents={[
{
uri:
fileExtension === 'csv' && isDefined(csvPreview)
? window.URL.createObjectURL(
new Blob([csvPreview], { type: 'text/csv' }),
)
: documentUrl,
uri: documentUrl,
fileName: documentName,
fileType: mimeType,
},
@@ -0,0 +1,81 @@
import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview';
const mockFetch = (text: string) => {
global.fetch = jest.fn(() =>
Promise.resolve({
text: () => Promise.resolve(text),
} as unknown as Response),
);
};
describe('fetchCsvPreview', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should parse headers and rows from CSV', async () => {
mockFetch('Name,Age,City\nAlice,30,Paris\nBob,25,London\n');
const result = await fetchCsvPreview('https://example.com/file.csv');
expect(result.headers).toEqual(['Name', 'Age', 'City']);
expect(result.rows).toEqual([
['Alice', '30', 'Paris'],
['Bob', '25', 'London'],
]);
});
it('should return empty headers and rows for empty CSV', async () => {
mockFetch('');
const result = await fetchCsvPreview('https://example.com/empty.csv');
expect(result.headers).toEqual([]);
expect(result.rows).toEqual([]);
});
it('should return headers with no rows when CSV has only a header line', async () => {
mockFetch('Name,Age,City\n');
const result = await fetchCsvPreview('https://example.com/header-only.csv');
expect(result.headers).toEqual(['Name', 'Age', 'City']);
expect(result.rows).toEqual([]);
});
it('should skip empty lines', async () => {
mockFetch('Name,Age\n\nAlice,30\n\nBob,25\n');
const result = await fetchCsvPreview('https://example.com/file.csv');
expect(result.rows).toEqual([
['Alice', '30'],
['Bob', '25'],
]);
});
it('should handle rows with inconsistent column counts', async () => {
mockFetch('Name,Age,City\nAlice,30\nBob,25,London,Extra\n');
const result = await fetchCsvPreview('https://example.com/malformed.csv');
expect(result.headers).toEqual(['Name', 'Age', 'City']);
expect(result.rows).toEqual([
['Alice', '30'],
['Bob', '25', 'London', 'Extra'],
]);
});
it('should limit rows to the preview amount', async () => {
const lines = ['Name'];
for (let i = 0; i < 100; i++) {
lines.push(`Person${i}`);
}
mockFetch(lines.join('\n'));
const result = await fetchCsvPreview('https://example.com/large.csv');
expect(result.headers).toEqual(['Name']);
expect(result.rows).toHaveLength(50);
});
});
@@ -2,21 +2,22 @@ import Papa from 'papaparse';
const DEFAULT_PREVIEW_ROWS = 50;
export const fetchCsvPreview = async (url: string): Promise<string> => {
export type CsvPreviewData = {
headers: string[];
rows: string[][];
};
export const fetchCsvPreview = async (url: string): Promise<CsvPreviewData> => {
const response = await fetch(url);
const text = await response.text();
const result = Papa.parse(text, {
preview: DEFAULT_PREVIEW_ROWS,
const result = Papa.parse<string[]>(text, {
preview: DEFAULT_PREVIEW_ROWS + 1, // +1 for header row
skipEmptyLines: true,
header: true,
header: false,
});
const data = result.data as Record<string, string>[];
const [headers = [], ...rows] = result.data;
const csvContent = Papa.unparse(data, {
header: true,
});
return csvContent;
return { headers, rows };
};
@@ -7,6 +7,7 @@ import {
import { useState } from 'react';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -37,6 +38,7 @@ export const useCustomResolver = <
fetchMoreRecords: () => Promise<void>;
} => {
const { enqueueErrorSnackBar } = useSnackBar();
const apolloCoreClient = useApolloCoreClient();
const [page, setPage] = useState({
pageNumber: 1,
@@ -62,6 +64,7 @@ export const useCustomResolver = <
loading: firstQueryLoading,
fetchMore,
} = useQuery<CustomResolverQueryResult<T>>(query, {
client: apolloCoreClient,
variables: queryVariables,
onError: (error) => {
enqueueErrorSnackBar({
@@ -1,28 +1,17 @@
import { useLinkedObjectsTitle } from '@/activities/timeline-activities/hooks/useLinkedObjectsTitle';
import { type TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
// do we need to test this?
export const useTimelineActivities = (
targetableObject: ActivityTargetableObject,
) => {
const isTimelineActivityMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_TIMELINE_ACTIVITY_MIGRATED,
);
const targetableObjectFieldIdName = isTimelineActivityMigrated
? `target${capitalize(targetableObject.targetObjectNameSingular)}Id`
: getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const targetableObjectFieldIdName = `target${capitalize(targetableObject.targetObjectNameSingular)}Id`;
const { objectMetadataItem: timelineActivityMetadata } =
useObjectMetadataItem({
@@ -30,6 +30,7 @@ const StyledEditorContainer = styled.div<{
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
@@ -54,15 +55,15 @@ const StyledEditorContainer = styled.div<{
}
h1 {
font-size: 32px;
font-size: 1.5em;
}
h2 {
font-size: 24px;
font-size: 1.3em;
}
h3 {
font-size: 16px;
font-size: 1.1em;
}
li {
@@ -1,52 +1,19 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSparkles } from 'twenty-ui/display';
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
const StyledEmptyState = styled.div`
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
justify-content: center;
justify-content: flex-end;
height: 100%;
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2.5)};
display: flex;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.lg};
color: ${({ theme }) => theme.font.color.primary};
font-weight: 600;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
max-width: 85%;
font-size: ${({ theme }) => theme.font.size.md};
`;
export const AIChatEmptyState = () => {
const theme = useTheme();
return (
<StyledEmptyState>
<StyledSparkleIcon>
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
</StyledSparkleIcon>
<StyledTitle>{t`Chat`}</StyledTitle>
<StyledDescription>
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
</StyledDescription>
<AIChatSuggestedPrompts />
</StyledEmptyState>
);
};
@@ -1,7 +1,5 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar, IconSparkles } from 'twenty-ui/display';
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
@@ -13,10 +11,11 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')};
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
width: 100%;
@@ -26,26 +25,17 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
}
`;
const StyledMessageRow = styled.div`
display: flex;
flex-direction: row;
align-items: flex-start;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledMessageText = styled.div<{ isUser?: boolean }>`
background: ${({ theme, isUser }) =>
isUser ? theme.background.secondary : theme.background.transparent};
border-radius: ${({ theme }) => theme.border.radius.md};
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
border: ${({ isUser, theme }) =>
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
isUser ? theme.background.tertiary : theme.background.transparent};
border-radius: ${({ theme, isUser }) =>
isUser ? theme.border.radius.sm : '0'};
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.light : theme.font.color.primary};
isUser ? theme.font.color.secondary : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
max-width: 100%;
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
width: fit-content;
word-wrap: break-word;
overflow-wrap: break-word;
/* Pre-wrap within the whole container turns every newline between block
@@ -114,23 +104,10 @@ const StyledMessageFooter = styled.div`
width: 100%;
`;
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
align-items: center;
background: ${({ theme, isUser }) =>
isUser
? theme.background.transparent.light
: theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledMessageContainer = styled.div`
const StyledMessageContainer = styled.div<{ isUser?: boolean }>`
max-width: 100%;
min-width: 0;
width: 100%;
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
`;
const StyledFilesContainer = styled.div`
@@ -150,68 +127,48 @@ export const AIChatMessage = ({
isLastMessageStreaming: boolean;
error?: Error | null;
}) => {
const theme = useTheme();
const { localeCatalog } = useRecoilValue(dateLocaleState);
const isUser = message.role === AgentMessageRole.USER;
const showError =
isDefined(error) && message.role === AgentMessageRole.ASSISTANT;
const fileParts = message.parts.filter((part) => part.type === 'file');
return (
<StyledMessageBubble
key={message.id}
isUser={message.role === AgentMessageRole.USER}
>
<StyledMessageRow>
{message.role === AgentMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
<StyledMessageBubble key={message.id} isUser={isUser}>
<StyledMessageContainer isUser={isUser}>
<StyledMessageText isUser={isUser}>
<AIChatAssistantMessageRenderer
isLastMessageStreaming={isLastMessageStreaming}
messageParts={message.parts}
hasError={showError}
/>
</StyledMessageText>
{fileParts.length > 0 && (
<StyledFilesContainer>
{fileParts.map((file) => (
<AgentChatFilePreview key={file.filename} file={file} />
))}
</StyledFilesContainer>
)}
{message.role === AgentMessageRole.USER && (
<StyledAvatarContainer isUser>
<Avatar size="sm" placeholder="U" type="rounded" />
</StyledAvatarContainer>
)}
<StyledMessageContainer>
<StyledMessageText isUser={message.role === AgentMessageRole.USER}>
<AIChatAssistantMessageRenderer
isLastMessageStreaming={isLastMessageStreaming}
messageParts={message.parts}
hasError={showError}
/>
</StyledMessageText>
{fileParts.length > 0 && (
<StyledFilesContainer>
{fileParts.map((file) => (
<AgentChatFilePreview key={file.filename} file={file} />
))}
</StyledFilesContainer>
)}
{showError && <AIChatErrorRenderer error={error} />}
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</span>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageContainer>
</StyledMessageRow>
{showError && <AIChatErrorRenderer error={error} />}
</StyledMessageContainer>
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(
message.metadata?.createdAt,
localeCatalog,
)}
</span>
<LightCopyIconButton
copyText={
message.parts.find((part) => part.type === 'text')?.text ?? ''
}
/>
</StyledMessageFooter>
)}
</StyledMessageBubble>
);
};
@@ -1,30 +1,11 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, IconSparkles } from 'twenty-ui/display';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
const StyledErrorContainer = styled.div`
display: flex;
flex-direction: row;
flex-direction: column;
align-items: flex-start;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledAvatarContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledContent = styled.div`
min-width: 0;
width: 100%;
`;
@@ -35,21 +16,9 @@ type AIChatStandaloneErrorProps = {
export const AIChatStandaloneError = ({
error,
}: AIChatStandaloneErrorProps) => {
const theme = useTheme();
return (
<StyledErrorContainer>
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
<StyledContent>
<AIChatErrorRenderer error={error} />
</StyledContent>
<AIChatErrorRenderer error={error} />
</StyledErrorContainer>
);
};
@@ -1,15 +1,14 @@
import { TextArea } from '@/ui/input/components/TextArea';
import styled from '@emotion/styled';
import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display';
import { IconHistory } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { DropZone } from '@/activities/files/components/DropZone';
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
@@ -17,15 +16,16 @@ import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContext
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { Button } from 'twenty-ui/input';
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
background: ${({ theme }) => theme.background.primary};
@@ -37,15 +37,56 @@ const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
flex-direction: column;
`;
const StyledInputArea = styled.div`
const StyledInputArea = styled.div<{ isMobile: boolean }>`
align-items: flex-end;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
padding-inline: ${({ theme }) => theme.spacing(3)};
padding-block: ${({ theme, isMobile }) => (isMobile ? 0 : theme.spacing(3))};
background: ${({ theme }) => theme.background.primary};
`;
const StyledInputBox = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
min-height: 140px;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
box-sizing: border-box;
&:focus-within {
border-color: ${({ theme }) => theme.color.blue};
box-shadow: 0px 0px 0px 3px ${({ theme }) => theme.color.transparent.blue2};
}
`;
const StyledTextAreaWrapper = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
`;
const StyledChatTextArea = styled(TextArea)`
&& {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
padding: 0;
}
&&:focus {
border: none;
box-shadow: none;
}
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
@@ -57,14 +98,16 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
`;
const StyledButtonsContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(0.5)};
justify-content: flex-end;
`;
export const AIChatTab = () => {
const [isDraggingFile, setIsDraggingFile] = useState(false);
const isMobile = useIsMobile();
const { isLoading, messages, isStreaming, error } =
useAgentChatContextOrThrow();
@@ -72,7 +115,6 @@ export const AIChatTab = () => {
useRecoilState(agentChatInputState);
const { uploadFiles } = useAIChatFileUpload();
const { createChatThread } = useCreateNewAIChatThread();
const { navigateCommandMenu } = useCommandMenu();
return (
@@ -115,45 +157,46 @@ export const AIChatTab = () => {
)}
</StyledScrollWrapper>
)}
{messages.length === 0 && !error && <AIChatEmptyState />}
{messages.length === 0 && !error && !isLoading && (
<AIChatEmptyState />
)}
{messages.length === 0 && error && !isLoading && (
<AIChatStandaloneError error={error} />
)}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<StyledInputArea isMobile={isMobile}>
<AgentChatContextPreview />
<TextArea
textAreaId={AI_CHAT_INPUT_ID}
placeholder={t`Enter a question...`}
value={agentChatInput}
onChange={(value) => setAgentChatInput(value)}
minRows={1}
maxRows={20}
/>
<StyledButtonsContainer>
<Button
variant="secondary"
size="small"
Icon={IconHistory}
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
/>
<Button
variant="secondary"
size="small"
Icon={IconMessageCirclePlus}
onClick={() => createChatThread()}
/>
<AgentChatFileUploadButton />
<AIChatContextUsageButton />
<SendMessageButton />
</StyledButtonsContainer>
<StyledInputBox>
<StyledTextAreaWrapper>
<StyledChatTextArea
textAreaId={AI_CHAT_INPUT_ID}
placeholder={t`Ask, search or make anything...`}
value={agentChatInput}
onChange={(value) => setAgentChatInput(value)}
minRows={3}
maxRows={20}
/>
</StyledTextAreaWrapper>
<StyledButtonsContainer>
<AIChatContextUsageButton />
<IconButton
Icon={IconHistory}
variant="tertiary"
size="small"
onClick={() =>
navigateCommandMenu({
page: CommandMenuPages.ViewPreviousAIChats,
pageTitle: t`View Previous AI Chats`,
pageIcon: IconHistory,
})
}
ariaLabel={t`View Previous AI Chats`}
/>
<AgentChatFileUploadButton />
<SendMessageButton />
</StyledButtonsContainer>
</StyledInputBox>
</StyledInputArea>
</>
)}
@@ -84,17 +84,18 @@ export const AIChatThreadGroup = ({
const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id);
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData =
totalTokens > 0 && isDefined(thread.contextWindowTokens);
(thread.conversationSize ?? 0) > 0 &&
isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -9,7 +9,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { useLingui } from '@lingui/react/macro';
import { type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
@@ -132,12 +135,10 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
const [activeTab, setActiveTab] = useState<TabType>('output');
const { input, output, type, errorText } = toolPart;
const toolName = type.split('-')[1];
const rawToolName = type.split('-')[1];
const toolInput =
isDefined(input) && typeof input === 'object' && 'input' in input
? input.input
: input;
const { resolvedInput: toolInput, resolvedToolName: toolName } =
resolveToolInput(input, rawToolName);
const hasError = isDefined(errorText);
const isExpandable = isDefined(output) || hasError;
@@ -175,7 +176,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
<StyledLoadingContainer>
<ShimmeringText>
<StyledDisplayMessage>
{getToolDisplayMessage(input, toolName, false)}
{getToolDisplayMessage(input, rawToolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
@@ -188,19 +189,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
);
}
// For execute_tool, the actual result is nested inside output.result
const unwrappedOutput =
rawToolName === 'execute_tool' &&
isDefined(output) &&
typeof output === 'object' &&
'result' in output
? (output as { result: unknown }).result
: output;
const displayMessage = hasError
? t`Tool execution failed`
: output &&
typeof output === 'object' &&
'message' in output &&
typeof output.message === 'string'
? output.message
: getToolDisplayMessage(input, toolName, true);
: rawToolName === 'learn_tools' || rawToolName === 'execute_tool'
? getToolDisplayMessage(input, rawToolName, true)
: unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'message' in unwrappedOutput &&
typeof unwrappedOutput.message === 'string'
? unwrappedOutput.message
: getToolDisplayMessage(input, rawToolName, true);
const result =
output && typeof output === 'object' && 'result' in output
? (output as { result: string }).result
: output;
unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'result' in unwrappedOutput
? (unwrappedOutput as { result: string }).result
: unwrappedOutput;
const ToolIcon = getToolIcon(toolName);
@@ -82,8 +82,10 @@ print("Chart saved successfully!")`,
usage: {
inputTokens: 1250,
outputTokens: 890,
cachedInputTokens: 0,
inputCredits: 12,
outputCredits: 8,
conversationSize: 1250,
},
},
};
@@ -1,12 +1,17 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ProgressBar } from 'twenty-ui/feedback';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
const StyledContainer = styled.div`
position: relative;
@@ -14,14 +19,11 @@ const StyledContainer = styled.div`
const StyledTrigger = styled.div<{ hasUsage: boolean }>`
align-items: center;
background: transparent;
border: 1px solid ${({ theme }) => theme.background.transparent.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
padding: 0 ${({ theme }) => theme.spacing(2)};
justify-content: center;
min-width: 24px;
transition: background 0.1s ease;
&:hover {
@@ -41,14 +43,14 @@ const StyledHoverCard = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
min-width: 240px;
min-width: 280px;
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
const StyledHeader = styled.div`
const StyledSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
@@ -61,14 +63,6 @@ const StyledRow = styled.div`
justify-content: space-between;
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
padding-top: 0;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
@@ -79,15 +73,16 @@ const StyledValue = styled.span`
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledFooter = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
const StyledSectionTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const StyledDivider = styled.div`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 0 0 ${({ theme }) => theme.border.radius.md}
${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(3)};
`;
const formatTokenCount = (count: number): string => {
@@ -103,6 +98,31 @@ const formatTokenCount = (count: number): string => {
return count.toString();
};
const formatCredits = (credits: number): string => {
// Credits are already in display units from the API (internal / 1000)
// Show up to 1 decimal for fractional values, none for whole numbers
if (Number.isInteger(credits)) {
return credits.toLocaleString();
}
return credits.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
};
const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => {
if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) {
return '';
}
const cachedPercent = Math.round(
(lastMessage.cachedInputTokens / lastMessage.inputTokens) * 100,
);
return ` (${t`${cachedPercent}% cached`})`;
};
export const AIChatContextUsageButton = () => {
const { t } = useLingui();
const theme = useTheme();
@@ -114,21 +134,20 @@ export const AIChatContextUsageButton = () => {
<StyledContainer>
<StyledTrigger hasUsage={false}>
<ContextUsageProgressRing percentage={0} />
<StyledPercentage>0%</StyledPercentage>
</StyledTrigger>
</StyledContainer>
);
}
const percentage = Math.min(
(agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100,
(agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) *
100,
100,
);
const formattedPercentage = percentage.toFixed(1);
const totalCredits =
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
const inputCredits = agentChatUsage.inputCredits.toLocaleString();
const outputCredits = agentChatUsage.outputCredits.toLocaleString();
const lastMessage = agentChatUsage.lastMessage;
return (
<StyledContainer
@@ -137,17 +156,17 @@ export const AIChatContextUsageButton = () => {
>
<StyledTrigger hasUsage={true}>
<ContextUsageProgressRing percentage={percentage} />
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
</StyledTrigger>
{isHovered && (
<StyledHoverCard>
<StyledHeader>
<StyledSection>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>
{formatTokenCount(agentChatUsage.totalTokens)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
{t`tokens`}
</StyledValue>
</StyledRow>
<ProgressBar
@@ -162,29 +181,61 @@ export const AIChatContextUsageButton = () => {
backgroundColor={theme.background.quaternary}
withBorderRadius
/>
</StyledHeader>
</StyledSection>
<StyledBody>
{isDefined(lastMessage) && (
<>
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.inputTokens)}
{getCachedLabel(lastMessage)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.outputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Cost`}</StyledLabel>
<StyledValue>
{formatCredits(
lastMessage.inputCredits + lastMessage.outputCredits,
)}{' '}
{t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</>
)}
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input`}</StyledLabel>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.inputTokens)} {' '}
{t`${inputCredits} credits`}
{formatTokenCount(agentChatUsage.inputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output`}</StyledLabel>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.outputTokens)} {' '}
{t`${outputCredits} credits`}
{formatTokenCount(agentChatUsage.outputTokens)}
</StyledValue>
</StyledRow>
</StyledBody>
<StyledFooter>
<StyledLabel>{t`Total credits`}</StyledLabel>
<StyledPercentage>{totalCredits.toLocaleString()}</StyledPercentage>
</StyledFooter>
<StyledRow>
<StyledLabel>{t`Total cost`}</StyledLabel>
<StyledValue>
{formatCredits(totalCredits)} {t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</StyledHoverCard>
)}
</StyledContainer>
@@ -1,10 +1,11 @@
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import React, { useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { IconPaperclip } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { IconPlus } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledFileUploadContainer = styled.div`
display: flex;
@@ -44,13 +45,14 @@ export const AgentChatFileUploadButton = () => {
onChange={handleFileInputChange}
/>
<Button
variant="secondary"
<IconButton
variant="tertiary"
size="small"
onClick={() => {
fileInputRef.current?.click();
}}
Icon={IconPaperclip}
Icon={IconPlus}
ariaLabel={t`Attach files`}
/>
</StyledFileUploadContainer>
);
@@ -13,7 +13,11 @@ const StyledSvg = styled.svg`
const StyledBackgroundCircle = styled.circle`
fill: none;
stroke: ${({ theme }) => theme.background.quaternary};
stroke: color-mix(
in srgb,
${({ theme }) => theme.border.color.strong} 50%,
${({ theme }) => theme.background.quaternary} 50%
);
`;
const StyledProgressCircle = styled.circle`
@@ -2,14 +2,15 @@ import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { Button } from 'twenty-ui/input';
import { IconArrowUp, IconPlayerStop } from 'twenty-ui/display';
import { RoundedIconButton } from 'twenty-ui/input';
export const SendMessageButton = () => {
const agentChatInput = useRecoilValue(agentChatInputState);
const { handleSendMessage, isLoading } = useAgentChatContextOrThrow();
const { handleSendMessage, handleStop, isLoading, isStreaming } =
useAgentChatContextOrThrow();
useHotkeysOnFocusedElement({
keys: [Key.Enter],
@@ -26,15 +27,22 @@ export const SendMessageButton = () => {
},
});
if (isStreaming) {
return (
<RoundedIconButton
Icon={IconPlayerStop}
size="medium"
onClick={() => handleStop()}
/>
);
}
return (
<Button
hotkeys={agentChatInput && !isLoading ? ['⏎'] : undefined}
<RoundedIconButton
Icon={IconArrowUp}
size="medium"
onClick={() => handleSendMessage()}
disabled={!agentChatInput || isLoading}
variant="primary"
accent="blue"
size="small"
title={t`Send`}
/>
);
};
@@ -0,0 +1,58 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useSetRecoilState } from 'recoil';
import { LightButton } from 'twenty-ui/input';
import {
DEFAULT_SUGGESTED_PROMPTS,
type SuggestedPrompt,
} from '@/ai/components/suggested-prompts/default-suggested-prompts';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
`;
const StyledSuggestedPromptButton = styled(LightButton)`
width: 100%;
`;
const pickRandom = <T,>(items: T[]): T =>
items[Math.floor(Math.random() * items.length)];
export const AIChatSuggestedPrompts = () => {
const { t: resolveMessage } = useLingui();
const setAgentChatInput = useSetRecoilState(agentChatInputState);
const handleClick = (prompt: SuggestedPrompt) => {
const picked = pickRandom(prompt.prefillPrompts);
setAgentChatInput(resolveMessage(picked));
};
return (
<StyledContainer>
<StyledTitle>{t`What can I help you with?`}</StyledTitle>
{DEFAULT_SUGGESTED_PROMPTS.map((prompt) => (
<StyledSuggestedPromptButton
key={prompt.id}
Icon={prompt.Icon}
title={resolveMessage(prompt.label)}
accent="secondary"
onClick={() => handleClick(prompt)}
/>
))}
</StyledContainer>
);
};
@@ -0,0 +1,48 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import {
type IconComponent,
IconLayoutDashboard,
IconPlus,
IconSettingsAutomation,
} from 'twenty-ui/display';
export type SuggestedPrompt = {
id: string;
label: MessageDescriptor;
Icon: IconComponent;
prefillPrompts: MessageDescriptor[];
};
export const DEFAULT_SUGGESTED_PROMPTS: SuggestedPrompt[] = [
{
id: 'dashboard',
label: msg`Create a dashboard`,
Icon: IconLayoutDashboard,
prefillPrompts: [
msg`Create a dashboard with a chart of deal value by pipeline stage (New, Meeting, Proposal, Negotiation, Closed Won/Lost) for the current quarter, and a table of my top 10 open opportunities with amount, stage and expected close date.`,
msg`Build a dashboard that shows: (1) total pipeline value by stage for the last 3 months, (2) count of deals won vs lost per month, (3) average deal size. Use our standard pipeline stages.`,
msg`I need a dashboard for lead conversion: number of new leads by source this month, how many moved to opportunity, and conversion rate by source. Include a simple table and a bar chart.`,
],
},
{
id: 'workflow',
label: msg`Create a workflow`,
Icon: IconSettingsAutomation,
prefillPrompts: [
msg`When a deal's stage changes to Closed Won, create a task assigned to the deal owner, due 7 days after the close date, with title "Post-sale check-in" and the company name in the description.`,
msg`When a new lead is created with source "Website", assign it to the sales rep whose territory (by region/country) matches the lead's address; if no match, assign to the team lead.`,
msg`When any deal with amount over $100,000 has its stage or amount updated, send a notification to the sales channel with the deal name, company, new stage, amount and owner.`,
],
},
{
id: 'record',
label: msg`Create a record`,
Icon: IconPlus,
prefillPrompts: [
msg`Add a new company we're in touch with (e.g. name, website, industry). Details: `,
msg`Create a new contact and link them to a company. Details: `,
msg`Log a new deal (company, amount, stage, expected close). Details: `,
],
},
];
@@ -8,7 +8,7 @@ export type AgentChatContextValue = {
error?: Error;
handleSendMessage: () => Promise<void>;
handleStop: () => void;
handleRetry: () => void;
};
@@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql`
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
createdAt
@@ -80,7 +80,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
}
};
const { sendMessage, messages, status, error, regenerate } = useChat({
const { sendMessage, messages, status, error, regenerate, stop } = useChat({
transport: new DefaultChatTransport({
api: `${REST_API_BASE_URL}/agent-chat/stream`,
headers: () => ({
@@ -119,8 +119,10 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
type UsageMetadata = {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
conversationSize: number;
};
type ModelMetadata = {
contextWindowTokens: number;
@@ -133,11 +135,17 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
if (isDefined(usage) && isDefined(model)) {
setAgentChatUsage((prev) => ({
lastMessage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cachedInputTokens: usage.cachedInputTokens,
inputCredits: usage.inputCredits,
outputCredits: usage.outputCredits,
},
conversationSize: usage.conversationSize,
contextWindowTokens: model.contextWindowTokens,
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
totalTokens:
(prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens,
contextWindowTokens: model.contextWindowTokens,
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
}));
@@ -177,6 +185,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
return {
messages,
handleSendMessage,
handleStop: stop,
isLoading,
isStreaming,
error,
@@ -23,16 +23,17 @@ const setUsageFromThread = (
thread: AgentChatThread,
setAgentChatUsage: SetterOrUpdater<AgentChatUsageState | null>,
) => {
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens);
const hasUsageData =
(thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -1,3 +1,7 @@
import { t } from '@lingui/core/macro';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type BrowsingContext } from '@/ai/types/BrowsingContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
@@ -7,9 +11,10 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { t } from '@lingui/core/macro';
import { useRecoilCallback } from 'recoil';
export const useGetBrowsingContext = () => {
const getBrowsingContext = useRecoilCallback(
@@ -61,11 +66,40 @@ export const useGetBrowsingContext = () => {
return null;
}
return {
const recordContext: BrowsingContext = {
type: 'recordPage',
objectNameSingular: objectMetadataItem.nameSingular,
recordId: targetedRecordsRule.selectedRecordIds[0],
};
const pageLayoutId = snapshot
.getLoadable(
recordStoreFamilySelector<string | null | undefined>({
recordId: targetedRecordsRule.selectedRecordIds[0],
fieldName: 'pageLayoutId',
}),
)
.getValue();
if (isDefined(pageLayoutId)) {
const tabListInstanceId =
getTabListInstanceIdFromPageLayoutId(pageLayoutId);
const activeTabId = snapshot
.getLoadable(
activeTabIdComponentState.atomFamily({
instanceId: tabListInstanceId,
}),
)
.getValue();
return {
...recordContext,
pageLayoutId,
activeTabId,
};
}
return recordContext;
}
if (

Some files were not shown because too many files have changed in this diff Show More