chore(settings): address review comments from PR 21072 (#21121)

## Summary

Round through bosiraphael's 31 review threads on the merged PR #21072
(discovery hero + ephemeral playground token). The user asked to apply
each suggestion only where it adds value, so this PR is split into three
buckets.

### Comments (~17 threads)

- Tightened security-rationale / CSS-gotcha / API-doc comments to one or
two factual lines
- Kept (shortened) the comments above `RequireAccessTokenGuard` call
sites — without them a future reader could remove the guard and silently
reopen the escalation hole
- Kept (shortened) the in-memory-only rationale on
`playgroundApiKeyState` for the same reason
- Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer`
— non-obvious and easy to break

### Structure / extraction

- Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants
file (one-export-per-file)
- Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across
queries/, hooks/, types/, utils/:
  - `graphql/queries/findManyApplicationsForToolTable.ts`
  - `graphql/queries/findManyMarketplaceAppsForToolTable.ts`
  - `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging)
  - `types/SettingsAgentToolItem|Application|MarketplaceApp`
  - `utils/getToolApplicationId|getToolLink`
- Extract `SettingsAiModelsTab` optimistic mutations into
`hooks/useSettingsAiModelsActions` (handleModelFieldChange,
handleUseRecommendedToggle, handleModelToggle,
handleToggleAllVisibleModels)
- Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool`
- Drop unnecessary `useMemo` wrappers on `heroTabs` arrays
(SettingsObjects, SettingsLayout)
- Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab:
`onToggleChange={setShowDeactivated}` (no longer wrapping with arrow +
read of stale `!showDeactivated`)

### Hero assets

- Replace placeholder `customize-illustration` with per-page exports
- Rename `layout/customize-illustration-{light,dark}.png` →
`layout/cover-{light,dark}.png`
- Add `cover-{light,dark}.png` for **applications** and **members**
(they were both pointing at the layout placeholder as a TODO)
- Overwrite `data-model/cover-*.png`, `playground/cover-*.png`,
`ai/ai-tools-cover-*.png` with the new exports

## Test plan

- [ ] `npx nx typecheck twenty-front` 
- [ ] `npx nx typecheck twenty-server` 
- [ ] `npx nx lint twenty-front`  (oxlint + oxfmt, 0 warnings/errors)
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the new hero illustration (light + dark)
- [ ] AI tab: tool list still loads, search + Custom/Managed/Standard
filters still work, "New Tool" still navigates to detail
- [ ] AI tab: Models tab — smart/fast model select, "Use best models
only" toggle, per-model checkboxes, toggle-all all still
optimistic+revert on error
- [ ] Skills tab: "Deactivated" toggle still flips show/hide
- [ ] Webhooks table still uses the 1fr 28px grid
This commit is contained in:
Félix Malfait
2026-06-02 07:23:14 +02:00
committed by GitHub
parent d6b3527552
commit 75df1f3997
62 changed files with 523 additions and 528 deletions
@@ -1779,6 +1779,7 @@ enum FeatureFlagKey {
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
IS_SETTINGS_DISCOVERY_HERO_ENABLED
}
type WorkspaceUrls {
@@ -1411,7 +1411,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -8732,7 +8732,8 @@ export const enumFeatureFlagKey = {
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const
}
export const enumIdentityProviderType = {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 70 KiB

@@ -289,6 +289,7 @@ export enum FeatureFlagKey {
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
}
@@ -1641,6 +1641,7 @@ export enum FeatureFlagKey {
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
}
@@ -24,9 +24,6 @@ export const useEnterLayoutCustomizationMode = () => {
const { navigateSidePanel } = useNavigateSidePanel();
const { enqueueWarningSnackBar } = useSnackBar();
// Returns whether customization mode is active afterward, so callers that
// navigate on entry can skip navigation when entry was blocked (e.g. a
// dashboard is mid-edit).
const enterLayoutCustomizationMode = useCallback((): boolean => {
const isLayoutCustomizationModeAlreadyEnabled = store.get(
isLayoutCustomizationModeEnabledState.atom,
@@ -4,11 +4,13 @@ import {
} from '@/settings/components/SettingsCustomizeVideoModal';
import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Card } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const COVER_HEIGHT = 150;
@@ -56,6 +58,9 @@ export const SettingsDiscoveryHeroCard = ({
const { t } = useLingui();
const { colorScheme } = useContext(ThemeContext);
const { openModal } = useModal();
const isDiscoveryVideoEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED,
);
const modalInstanceId = `${instanceIdPrefix}-modal`;
const tabsInstanceId = `${instanceIdPrefix}-tabs`;
@@ -67,19 +72,23 @@ export const SettingsDiscoveryHeroCard = ({
<Card rounded>
<StyledCoverContainer>
<StyledImage src={src} alt="" aria-hidden />
<StyledOverlay>
<HeroPlayButton
onClick={() => openModal(modalInstanceId)}
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
/>
</StyledOverlay>
{isDiscoveryVideoEnabled && (
<StyledOverlay>
<HeroPlayButton
onClick={() => openModal(modalInstanceId)}
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
/>
</StyledOverlay>
)}
</StyledCoverContainer>
</Card>
<SettingsCustomizeVideoModal
modalInstanceId={modalInstanceId}
tabsInstanceId={tabsInstanceId}
tabs={tabs}
/>
{isDiscoveryVideoEnabled && (
<SettingsCustomizeVideoModal
modalInstanceId={modalInstanceId}
tabsInstanceId={tabsInstanceId}
tabs={tabs}
/>
)}
</>
);
};
@@ -6,15 +6,10 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export type SettingsStatRow = {
Icon: IconComponent;
label: string;
// String so callers can render a placeholder (e.g. "—") while async counts
// are still loading. Layout stats just pass `count.toString()`.
value: string;
};
type SettingsStatsGridProps = {
// Each inner array is one column rendered top-to-bottom; columns are
// separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout
// or [[a, b, c]] for a single column.
columns: SettingsStatRow[][];
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 40 KiB

@@ -3,12 +3,8 @@ import { styled } from '@linaria/react';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
import React from 'react';
// Column width used by the Applications data tables (Instances column).
export const SETTINGS_OBJECT_TABLE_COLUMN_WIDTH = '98.7px';
// Relative grid: Name takes all remaining space (with a floor); App / Fields /
// Instances get fixed minimums so short text columns don't collapse; trailing
// 36 px holds the chevron / action cell.
export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `minmax(180px, 1fr) 140px 80px 100px 36px`;
export const SETTINGS_OBJECT_TABLE_ROW_MOBILE_MIN_WIDTH = '520px';
@@ -58,7 +58,6 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
return;
}
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit).
if (!enterLayoutCustomizationMode()) {
return;
}
@@ -1,5 +1,6 @@
import { styled } from '@linaria/react';
import { WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS } from '@/settings/developers/constants/WebhookTableRowGridTemplateColumns';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils';
@@ -11,8 +12,6 @@ import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type Webhook } from '~/generated-metadata/graphql';
export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
const StyledIconChevronRightContainer = styled.span`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
@@ -1,9 +1,7 @@
import { styled } from '@linaria/react';
import {
SettingsDevelopersWebhookTableRow,
WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
} from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import { WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS } from '@/settings/developers/constants/WebhookTableRowGridTemplateColumns';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -0,0 +1 @@
export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 111 KiB

@@ -19,9 +19,6 @@ const playgroundSetupFormSchema = z.object({
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
// Last column shrinks to the Launch button's content width so its right
// edge sits at the form's right edge. The two select columns share the
// remaining space equally.
const StyledForm = styled.form`
align-items: end;
display: grid;
@@ -1,11 +1,12 @@
import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof PlaygroundSetupForm> = {
title: 'Modules/Settings/Playground/PlaygroundSetupForm',
component: PlaygroundSetupForm,
decorators: [ComponentDecorator, RouterDecorator],
decorators: [ComponentDecorator, RouterDecorator, SnackBarDecorator],
parameters: {
docs: {
description: {
@@ -2,17 +2,13 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomStat
import { isDefined } from 'twenty-shared/utils';
import { type AuthToken } from '~/generated-metadata/graphql';
// In-memory only: a short-lived, full-permission bearer token. Keeping it out of
// localStorage bounds the exfiltration window to the current tab and leaves no
// usable credential at rest after the tab closes.
// In-memory only — never persist this bearer token to localStorage.
export const playgroundApiKeyState = createAtomState<AuthToken | null>({
key: 'playgroundApiKeyState',
defaultValue: null,
});
// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass
// no buffer (reject the moment it expires); the launcher passes a buffer so it
// re-mints before a near-expired token can fail mid-session.
// Returns true when the token is still valid `bufferMs` from now.
export const isPlaygroundApiKeyFresh = (
token: AuthToken | null,
bufferMs = 0,
@@ -20,9 +20,6 @@ type SubMenuTopBarContainerProps = {
tag?: JSX.Element;
};
// Cards, forms, and tables inside the white panel are centered in a fixed
// max-width column so they don't sprawl on large displays. The white panel
// itself spans edge-to-edge; only the content is constrained.
const SETTINGS_CONTENT_MAX_WIDTH = 760;
const StyledContainer = styled.div`
@@ -31,10 +28,8 @@ const StyledContainer = styled.div`
width: 100%;
`;
// flex: 1 + min-height: 0 keep the vertical-scroll chain intact: PagePanel's
// own overflow handling sits one level up and depends on its children
// participating in the flex height calculation rather than collapsing to
// content height.
// flex: 1 + min-height: 0 are required for PagePanel's overflow chain — the
// child must participate in the flex height calc rather than collapse to content.
const StyledBodyContentWrapper = styled.div`
display: flex;
flex: 1;
@@ -72,12 +67,6 @@ export const SubMenuTopBarContainer = ({
<PageHeader title={<Breadcrumb links={links} />}>
{actionButton}
</PageHeader>
{/*
MainContainerLayoutWithSidePanel is the same wrapper the App's record
pages use: it renders the page body on the left and SidePanelForDesktop
on the right. Hosting it here lets the AI chat side panel (and any
other side-panel page) open in settings exactly as it does in the App.
*/}
<MainContainerLayoutWithSidePanel>
<StyledBodyContentWrapper>
<InformationBannerWrapper />
@@ -5,24 +5,13 @@ import { type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
type CollapsibleNavigationDrawerSectionProps = {
// Unique id used to persist the open/closed state in localStorage. Pass
// a namespaced value (e.g. 'settings/User') so unrelated sections in
// different drawers don't share state.
// Namespaced id (e.g. 'settings/User') used to persist open/closed state.
sectionId: string;
label: string;
children: ReactNode;
// Optional wrapper around the section title (e.g. AdvancedSettingsWrapper
// for advanced-mode-only sections). Receives the title node and returns
// the wrapped node.
wrapTitle?: (titleNode: ReactNode) => ReactNode;
};
// One-stop section component for any drawer that wants the main-app's
// collapsible section behavior: click the title to collapse / expand,
// animated height transition, persisted open state, chevron-on-hover.
// Use this instead of stitching together NavigationDrawerSection +
// NavigationDrawerSectionTitle + AnimatedExpandableContainer by hand at
// every call site.
export const CollapsibleNavigationDrawerSection = ({
sectionId,
label,
@@ -5,8 +5,6 @@ import { styled } from '@linaria/react';
import { useIsMobile } from 'twenty-ui/utilities';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mobile keeps the touch-friendly horizontal padding; on desktop the container
// is edge-to-edge and the child supplies its own padding.
const StyledFixedContainer = styled.div<{ isMobile?: boolean }>`
padding-left: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[5] : '0'};
@@ -52,9 +52,6 @@ export type NavigationDrawerItemProps = {
onClick?: () => void;
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
iconColor?: string | null;
// Wrap the plain icon in a soft grey tile (no border) — used by the
// settings drawer so its icons read as a uniform group without picking
// up TintedIconTile's bordered colored treatment.
withIconBackground?: boolean;
active?: boolean;
modifier?: NavigationDrawerItemModifier;
@@ -206,10 +203,6 @@ const StyledIcon = styled.div`
margin-right: ${themeCssVariables.spacing[2]};
`;
// Soft grey background-only tile (no border) used by the settings drawer.
// Sized one step larger than the icon so the icon sits with a couple of
// pixels of breathing room on every side. radius.md matches the rest of
// the App's small-card / tile language; radius.sm read as sharp squares.
const StyledIconBackgroundTile = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
@@ -1,16 +1,11 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction';
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { getSettingsPath } from 'twenty-shared/utils';
import { t } from '@lingui/core/macro';
import {
@@ -30,6 +25,7 @@ import { SettingsAiModelsTab } from '~/pages/settings/ai/components/SettingsAiMo
import { SettingsAiOverviewTab } from '~/pages/settings/ai/components/SettingsAiOverviewTab';
import { SettingsAiUsageTab } from '~/pages/settings/ai/components/SettingsAiUsageTab';
import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs';
import { useCreateTool } from '~/pages/settings/ai/hooks/useCreateTool';
const AI_HERO_LIGHT = '/images/ai/ai-tools-cover-light.png';
const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png';
@@ -37,55 +33,13 @@ const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png';
const SETTINGS_AI_HERO_INSTANCE_ID_PREFIX = 'settings-ai-hero';
export const SettingsAI = () => {
const navigate = useNavigate();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { createLogicFunction } = usePersistLogicFunction();
const [isCreatingTool, setIsCreatingTool] = useState(false);
const { handleCreateTool, isCreatingTool } = useCreateTool();
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID,
);
const handleCreateTool = async () => {
setIsCreatingTool(true);
try {
const result = await createLogicFunction({
input: {
name: 'new-tool',
toolTriggerSettings: {
inputSchema: { type: 'object', properties: {} },
},
},
});
if (result.status === 'successful' && isDefined(result.response?.data)) {
const newLogicFunction = result.response.data.createOneLogicFunction;
enqueueSuccessSnackBar({ message: t`Tool created` });
const applicationId = newLogicFunction.applicationId;
if (isDefined(applicationId)) {
navigate(
getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
applicationId,
logicFunctionId: newLogicFunction.id,
}),
);
} else {
navigate(
getSettingsPath(SettingsPath.LogicFunctionDetail, {
logicFunctionId: newLogicFunction.id,
}),
);
}
} else {
enqueueErrorSnackBar({ message: t`Failed to create tool` });
}
} finally {
setIsCreatingTool(false);
}
};
const tabs = [
{
id: SETTINGS_AI_TABS.TABS_IDS.OVERVIEW,
@@ -104,7 +104,7 @@ export const SettingsAgentSkillsTab = () => {
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeactivated(!showDeactivated)}
onToggleChange={setShowDeactivated}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
@@ -1,80 +1,35 @@
import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useState } from 'react';
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ToolCategory } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { H2Title, IconLock, IconPuzzle, IconTool } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
type SettingsAgentToolItem,
SettingsAgentToolsTable,
} from '~/pages/settings/ai/components/SettingsAgentToolsTable';
import { SettingsAgentToolsTable } from '~/pages/settings/ai/components/SettingsAgentToolsTable';
import { useSettingsAgentToolsTable } from '~/pages/settings/ai/hooks/useSettingsAgentToolsTable';
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
query FindManyApplicationsForToolTable {
findManyApplications {
id
name
universalIdentifier
logo
}
}
`;
const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql`
query FindManyMarketplaceAppsForToolTable {
findManyMarketplaceApps {
id
universalIdentifier
icon
logo
}
}
`;
const StyledSearchContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
export const SettingsAgentToolsTab = () => {
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const {
toolIndex,
loading: toolIndexLoading,
error: toolIndexError,
} = useGetToolIndex();
const { data: applicationsData } = useQuery<{
findManyApplications: Array<{
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
}>;
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
const { data: marketplaceAppsData } = useQuery<{
findManyMarketplaceApps: Array<{
id: string;
universalIdentifier: string;
icon: string;
logo?: string | null;
}>;
}>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE);
const { t } = useLingui();
const {
allTools,
applicationById,
marketplaceAppByUniversalIdentifier,
currentWorkspace,
isLoading,
} = useSettingsAgentToolsTable();
const [searchTerm, setSearchTerm] = useState('');
const [showCustomTools, setShowCustomTools] = useState(true);
const [showManagedTools, setShowManagedTools] = useState(true);
@@ -89,42 +44,6 @@ export const SettingsAgentToolsTab = () => {
const isCustom = (tool: SettingsAgentToolItem) =>
isDefined(tool.applicationId);
const allTools: SettingsAgentToolItem[] = useMemo(
() => [
...logicFunctions
.filter((fn) => isDefined(fn.toolTriggerSettings))
.map((fn) => ({
identifier: fn.id,
name: fn.name,
description: fn.description,
applicationId: fn.applicationId,
})),
...toolIndex
.filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION)
.map((tool) => ({
identifier: tool.name,
name: tool.name,
description: tool.description,
category: tool.category,
objectName: tool.objectName,
icon: tool.icon,
})),
],
[logicFunctions, toolIndex],
);
const applicationById = new Map(
(applicationsData?.findManyApplications ?? []).map((application) => [
application.id,
application,
]),
);
const marketplaceAppByUniversalIdentifier = new Map(
(marketplaceAppsData?.findManyMarketplaceApps ?? []).map(
(marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp],
),
);
const filteredTools = allTools
.filter((tool) => {
const searchNormalized = normalizeSearchText(searchTerm);
@@ -149,8 +68,6 @@ export const SettingsAgentToolsTab = () => {
})
.sort((a, b) => a.name.localeCompare(b.name));
const isLoading = toolIndexLoading && !toolIndexError;
return (
<Section>
<H2Title
@@ -173,27 +90,21 @@ export const SettingsAgentToolsTab = () => {
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconTool}
onToggleChange={() =>
setShowCustomTools(!showCustomTools)
}
onToggleChange={setShowCustomTools}
toggled={showCustomTools}
text={t`Custom`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconLock}
onToggleChange={() =>
setShowManagedTools(!showManagedTools)
}
onToggleChange={setShowManagedTools}
toggled={showManagedTools}
text={t`Managed`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconPuzzle}
onToggleChange={() =>
setShowStandardTools(!showStandardTools)
}
onToggleChange={setShowStandardTools}
toggled={showStandardTools}
text={t`Standard`}
toggleSize="small"
@@ -1,53 +1,34 @@
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { SettingsToolIcon } from '~/pages/settings/ai/components/SettingsToolIcon';
import {
SettingsToolTableRow,
TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
} from '~/pages/settings/ai/components/SettingsToolTableRow';
export type SettingsAgentToolItem = {
identifier: string;
name: string;
description?: string | null;
category?: string;
objectName?: string | null;
icon?: string | null;
applicationId?: string | null;
};
type Application = {
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
};
type MarketplaceApp = {
id: string;
universalIdentifier: string;
icon: string;
logo?: string | null;
};
import { type SettingsAgentToolApplication } from '~/pages/settings/ai/types/SettingsAgentToolApplication';
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
import { type SettingsAgentToolMarketplaceApp } from '~/pages/settings/ai/types/SettingsAgentToolMarketplaceApp';
import { getToolApplicationId } from '~/pages/settings/ai/utils/getToolApplicationId';
import { getToolLink } from '~/pages/settings/ai/utils/getToolLink';
type SettingsAgentToolsTableProps = {
tools: SettingsAgentToolItem[];
isLoading: boolean;
applicationById: Map<string, Application>;
marketplaceAppByUniversalIdentifier: Map<string, MarketplaceApp>;
applicationById: Map<string, SettingsAgentToolApplication>;
marketplaceAppByUniversalIdentifier: Map<
string,
SettingsAgentToolMarketplaceApp
>;
currentWorkspace: CurrentWorkspace | null;
};
@@ -55,28 +36,6 @@ const StyledTableHeaderRowContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const getToolApplicationId = (
tool: SettingsAgentToolItem,
currentWorkspace: CurrentWorkspace | null,
): string => {
if (isDefined(tool.applicationId)) {
return tool.applicationId;
}
return (
currentWorkspace?.installedApplications?.find(
(app) =>
app.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
)?.id ?? ''
);
};
const getToolLink = (tool: SettingsAgentToolItem): string =>
getSettingsPath(SettingsPath.AiToolDetail, {
toolIdentifier: tool.identifier,
});
export const SettingsAgentToolsTable = ({
tools,
isLoading,
@@ -10,17 +10,17 @@ import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation, useQuery } from '@apollo/client/react';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconBolt,
@@ -32,12 +32,8 @@ import { SearchInput } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
GetAiSystemPromptPreviewDocument,
UpdateWorkspaceDocument,
} from '~/generated-metadata/graphql';
import { GetAiSystemPromptPreviewDocument } from '~/generated-metadata/graphql';
import { useSettingsAiModelsActions } from '~/pages/settings/ai/hooks/useSettingsAiModelsActions';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledCustomModelsContainer = styled.div`
@@ -49,14 +45,19 @@ const StyledCustomModelsContainer = styled.div`
export const SettingsAiModelsTab = () => {
const { theme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const [searchQuery, setSearchQuery] = useState('');
const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument);
const aiModels = useAtomStateValue(aiModelsState);
const { useRecommendedModels, realModels, enabledModels } =
useWorkspaceAiModelAvailability();
const {
handleModelFieldChange,
handleUseRecommendedToggle,
handleModelToggle,
handleToggleAllVisibleModels,
} = useSettingsAiModelsActions();
const systemPromptTokenCount =
previewData?.getAiSystemPromptPreview.estimatedTokenCount;
@@ -67,9 +68,6 @@ export const SettingsAiModelsTab = () => {
)} tokens)`
: t`Read the system prompts to understand how the AI works`;
const { useRecommendedModels, realModels, enabledModels } =
useWorkspaceAiModelAvailability();
const currentSmartModel = currentWorkspace?.smartModel;
const currentFastModel = currentWorkspace?.fastModel;
@@ -103,110 +101,11 @@ export const SettingsAiModelsTab = () => {
};
});
const handleModelFieldChange = async (
field: 'smartModel' | 'fastModel',
value: string,
) => {
if (!currentWorkspace?.id) return;
const previousValue = currentWorkspace[field];
try {
setCurrentWorkspace({ ...currentWorkspace, [field]: value });
await updateWorkspace({ variables: { input: { [field]: value } } });
} catch {
setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue });
enqueueErrorSnackBar({ message: t`Failed to update model` });
}
};
const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []);
const handleUseRecommendedToggle = async (checked: boolean) => {
if (!currentWorkspace?.id) {
return;
}
const previousValue = currentWorkspace.useRecommendedModels;
let newEnabledIds = currentWorkspace.enabledAiModelIds ?? [];
if (!checked && previousValue) {
const recommendedModelIds = realModels
.filter((model) => model.isRecommended)
.map((model) => model.modelId);
newEnabledIds = recommendedModelIds;
}
try {
setCurrentWorkspace({
...currentWorkspace,
useRecommendedModels: checked,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: {
input: {
useRecommendedModels: checked,
enabledAiModelIds: newEnabledIds,
},
},
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
useRecommendedModels: previousValue,
});
enqueueErrorSnackBar({
message: t`Failed to update model selection mode`,
});
}
};
const handleModelToggle = async (
modelId: string,
isCurrentlyEnabled: boolean,
) => {
if (!currentWorkspace?.id) {
return;
}
const previousEnabled = currentWorkspace.enabledAiModelIds ?? [];
const newEnabledIds = isCurrentlyEnabled
? previousEnabled.filter((id) => id !== modelId)
: [...previousEnabled, modelId];
try {
setCurrentWorkspace({
...currentWorkspace,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: {
input: {
enabledAiModelIds: newEnabledIds,
},
},
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
enabledAiModelIds: previousEnabled,
});
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
};
const filteredModels = searchQuery.trim()
? realModels.filter((model) => {
const query = searchQuery.toLowerCase();
return (
model.label.toLowerCase().includes(query) ||
(model.modelFamily?.toLowerCase().includes(query) ?? false) ||
@@ -284,34 +183,12 @@ export const SettingsAiModelsTab = () => {
models={filteredModels}
isChecked={(model) => enabledModelIdSet.has(model.modelId)}
onToggle={handleModelToggle}
onToggleAll={async (shouldCheckAll) => {
const previousIds = currentWorkspace?.enabledAiModelIds ?? [];
const visibleModelIds = new Set(
filteredModels.map((m) => m.modelId),
);
const newEnabledIds = shouldCheckAll
? [...new Set([...previousIds, ...visibleModelIds])]
: previousIds.filter((id) => !visibleModelIds.has(id));
try {
setCurrentWorkspace({
...currentWorkspace!,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: { input: { enabledAiModelIds: newEnabledIds } },
});
} catch {
setCurrentWorkspace({
...currentWorkspace!,
enabledAiModelIds: previousIds,
});
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
}}
onToggleAll={(shouldCheckAll) =>
handleToggleAllVisibleModels(
shouldCheckAll,
new Set(filteredModels.map((m) => m.modelId)),
)
}
anchorPrefix="workspace-model-row"
/>
</StyledCustomModelsContainer>
@@ -22,7 +22,6 @@ type ApplicationInfo = {
};
type MarketplaceAppInfo = {
icon: string;
logo?: string | null;
};
@@ -109,11 +108,6 @@ export const SettingsToolIcon = ({
);
}
if (isDefined(marketplaceApp)) {
const MarketplaceIcon = getIcon(marketplaceApp.icon);
return <MarketplaceIcon size={16} />;
}
if (isDefined(application)) {
return (
<Avatar
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
query FindManyApplicationsForToolTable {
findManyApplications {
id
name
universalIdentifier
logo
}
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql`
query FindManyMarketplaceAppsForToolTable {
findManyMarketplaceApps {
id
logo
}
}
`;
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useCreateTool = () => {
const navigate = useNavigate();
const { enqueueSuccessSnackBar } = useSnackBar();
const { createLogicFunction } = usePersistLogicFunction();
const [isCreatingTool, setIsCreatingTool] = useState(false);
const handleCreateTool = async () => {
setIsCreatingTool(true);
try {
const result = await createLogicFunction({
input: {
name: 'new-tool',
toolTriggerSettings: {
inputSchema: { type: 'object', properties: {} },
},
},
});
// Failure path already surfaces its own snackbar from usePersistLogicFunction.
if (result.status !== 'successful' || !isDefined(result.response?.data)) {
return;
}
const newLogicFunction = result.response.data.createOneLogicFunction;
enqueueSuccessSnackBar({ message: t`Tool created` });
const applicationId = newLogicFunction.applicationId;
if (isDefined(applicationId)) {
navigate(
getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
applicationId,
logicFunctionId: newLogicFunction.id,
}),
);
} else {
navigate(
getSettingsPath(SettingsPath.LogicFunctionDetail, {
logicFunctionId: newLogicFunction.id,
}),
);
}
} finally {
setIsCreatingTool(false);
}
};
return { handleCreateTool, isCreatingTool };
};
@@ -0,0 +1,81 @@
import { useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ToolCategory } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE } from '~/pages/settings/ai/graphql/queries/findManyApplicationsForToolTable';
import { FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE } from '~/pages/settings/ai/graphql/queries/findManyMarketplaceAppsForToolTable';
import { type SettingsAgentToolApplication } from '~/pages/settings/ai/types/SettingsAgentToolApplication';
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
import { type SettingsAgentToolMarketplaceApp } from '~/pages/settings/ai/types/SettingsAgentToolMarketplaceApp';
export const useSettingsAgentToolsTable = () => {
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const {
toolIndex,
loading: toolIndexLoading,
error: toolIndexError,
} = useGetToolIndex();
const { data: applicationsData } = useQuery<{
findManyApplications: SettingsAgentToolApplication[];
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
const { data: marketplaceAppsData } = useQuery<{
findManyMarketplaceApps: SettingsAgentToolMarketplaceApp[];
}>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE);
const allTools: SettingsAgentToolItem[] = useMemo(
() => [
...logicFunctions
.filter((fn) => isDefined(fn.toolTriggerSettings))
.map((fn) => ({
identifier: fn.id,
name: fn.name,
description: fn.description,
applicationId: fn.applicationId,
})),
...toolIndex
.filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION)
.map((tool) => ({
identifier: tool.name,
name: tool.name,
description: tool.description,
category: tool.category,
objectName: tool.objectName,
icon: tool.icon,
})),
],
[logicFunctions, toolIndex],
);
const applicationById = new Map(
(applicationsData?.findManyApplications ?? []).map((application) => [
application.id,
application,
]),
);
// MarketplaceApp.id IS the universal identifier — see
// marketplace-query.service.ts where `id: registration.universalIdentifier`.
const marketplaceAppByUniversalIdentifier = new Map(
(marketplaceAppsData?.findManyMarketplaceApps ?? []).map(
(marketplaceApp) => [marketplaceApp.id, marketplaceApp],
),
);
const isLoading = toolIndexLoading && !toolIndexError;
return {
allTools,
applicationById,
marketplaceAppByUniversalIdentifier,
currentWorkspace,
isLoading,
};
};
@@ -0,0 +1,137 @@
import { useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
export const useSettingsAiModelsActions = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const { realModels } = useWorkspaceAiModelAvailability();
const handleModelFieldChange = async (
field: 'smartModel' | 'fastModel',
value: string,
) => {
if (!currentWorkspace?.id) return;
const previousValue = currentWorkspace[field];
try {
setCurrentWorkspace({ ...currentWorkspace, [field]: value });
await updateWorkspace({ variables: { input: { [field]: value } } });
} catch {
setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue });
enqueueErrorSnackBar({ message: t`Failed to update model` });
}
};
const handleUseRecommendedToggle = async (checked: boolean) => {
if (!currentWorkspace?.id) return;
const previousValue = currentWorkspace.useRecommendedModels;
let newEnabledIds = currentWorkspace.enabledAiModelIds ?? [];
if (!checked && previousValue) {
newEnabledIds = realModels
.filter((model) => model.isRecommended)
.map((model) => model.modelId);
}
try {
setCurrentWorkspace({
...currentWorkspace,
useRecommendedModels: checked,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: {
input: {
useRecommendedModels: checked,
enabledAiModelIds: newEnabledIds,
},
},
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
useRecommendedModels: previousValue,
});
enqueueErrorSnackBar({
message: t`Failed to update model selection mode`,
});
}
};
const handleModelToggle = async (
modelId: string,
isCurrentlyEnabled: boolean,
) => {
if (!currentWorkspace?.id) return;
const previousEnabled = currentWorkspace.enabledAiModelIds ?? [];
const newEnabledIds = isCurrentlyEnabled
? previousEnabled.filter((id) => id !== modelId)
: [...previousEnabled, modelId];
try {
setCurrentWorkspace({
...currentWorkspace,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: { input: { enabledAiModelIds: newEnabledIds } },
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
enabledAiModelIds: previousEnabled,
});
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
};
const handleToggleAllVisibleModels = async (
shouldCheckAll: boolean,
visibleModelIds: Set<string>,
) => {
if (!currentWorkspace?.id) return;
const previousIds = currentWorkspace.enabledAiModelIds ?? [];
const newEnabledIds = shouldCheckAll
? [...new Set([...previousIds, ...visibleModelIds])]
: previousIds.filter((id) => !visibleModelIds.has(id));
try {
setCurrentWorkspace({
...currentWorkspace,
enabledAiModelIds: newEnabledIds,
});
await updateWorkspace({
variables: { input: { enabledAiModelIds: newEnabledIds } },
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
enabledAiModelIds: previousIds,
});
enqueueErrorSnackBar({
message: t`Failed to update model availability`,
});
}
};
return {
handleModelFieldChange,
handleUseRecommendedToggle,
handleModelToggle,
handleToggleAllVisibleModels,
};
};
@@ -0,0 +1,6 @@
export type SettingsAgentToolApplication = {
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
};
@@ -0,0 +1,9 @@
export type SettingsAgentToolItem = {
identifier: string;
name: string;
description?: string | null;
category?: string;
objectName?: string | null;
icon?: string | null;
applicationId?: string | null;
};
@@ -0,0 +1,4 @@
export type SettingsAgentToolMarketplaceApp = {
id: string;
logo?: string | null;
};
@@ -0,0 +1,21 @@
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
export const getToolApplicationId = (
tool: SettingsAgentToolItem,
currentWorkspace: CurrentWorkspace | null,
): string => {
if (isDefined(tool.applicationId)) {
return tool.applicationId;
}
return (
currentWorkspace?.installedApplications?.find(
(app) =>
app.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
)?.id ?? ''
);
};
@@ -0,0 +1,8 @@
import { type SettingsAgentToolItem } from '~/pages/settings/ai/types/SettingsAgentToolItem';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
export const getToolLink = (tool: SettingsAgentToolItem): string =>
getSettingsPath(SettingsPath.AiToolDetail, {
toolIdentifier: tool.identifier,
});
@@ -11,9 +11,8 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload, IconPlug } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
// TODO: replace with apps-specific illustrations + recordings when designed.
import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
import coverDark from '~/pages/settings/applications/assets/cover-dark.png';
import coverLight from '~/pages/settings/applications/assets/cover-light.png';
import {
FeatureFlagKey,
PermissionFlagType,
@@ -82,8 +81,8 @@ export const SettingsApplications = () => {
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={placeholderHeroLight}
darkSrc={placeholderHeroDark}
lightSrc={coverLight}
darkSrc={coverDark}
instanceIdPrefix={APPLICATIONS_HERO_INSTANCE_ID_PREFIX}
tabs={[
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -5,7 +5,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
@@ -31,29 +30,26 @@ export const SettingsObjects = () => {
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const heroTabs = useMemo(
() => [
{
id: 'objects',
title: t`Objects`,
Icon: IconHierarchy2,
vimeoId: '926288174',
},
{
id: 'fields',
title: t`Fields`,
Icon: IconList,
vimeoId: '927628219',
},
{
id: 'relations',
title: t`Relations`,
Icon: IconLink,
vimeoId: '1185511827',
},
],
[t],
);
const heroTabs = [
{
id: 'objects',
title: t`Objects`,
Icon: IconHierarchy2,
vimeoId: '926288174',
},
{
id: 'fields',
title: t`Fields`,
Icon: IconList,
vimeoId: '927628219',
},
{
id: 'relations',
title: t`Relations`,
Icon: IconLink,
vimeoId: '1185511827',
},
];
return (
<SubMenuTopBarContainer
@@ -4,7 +4,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
@@ -19,8 +18,8 @@ import {
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import customizeIllustrationDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import customizeIllustrationLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
import coverDark from '~/pages/settings/layout/assets/cover-dark.png';
import coverLight from '~/pages/settings/layout/assets/cover-light.png';
const SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX = 'settings-layout-hero';
@@ -29,52 +28,44 @@ export const SettingsLayout = () => {
const navigate = useNavigate();
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
// Match the in-app customization entry points: arm customization mode before
// landing in the app, otherwise the user just drops into the app normally.
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit) so we
// don't yank the user away after the warning.
const handleCustomize = () => {
if (enterLayoutCustomizationMode()) {
navigate(AppPath.Index);
}
};
// TODO: replace placeholder demo videos per tab
const heroTabs = useMemo(
() => [
{
id: 'sidebar',
title: t`Sidebar`,
Icon: IconLayoutSidebarLeftExpand,
vimeoId: '1185511790',
},
{
id: 'record-page',
title: t`Record page`,
Icon: IconAppWindow,
vimeoId: '1185511790',
},
{
id: 'command-menu',
title: t`Command menu`,
Icon: IconCommand,
vimeoId: '1185416775',
},
{
id: 'views',
title: t`Views`,
Icon: IconTable,
vimeoId: '1145648745',
},
{
id: 'dashboards',
title: t`Dashboards`,
Icon: IconLayoutDashboard,
vimeoId: '1185511768',
},
],
[t],
);
const heroTabs = [
{
id: 'sidebar',
title: t`Sidebar`,
Icon: IconLayoutSidebarLeftExpand,
vimeoId: '1185511790',
},
{
id: 'record-page',
title: t`Record page`,
Icon: IconAppWindow,
vimeoId: '1185511790',
},
{
id: 'command-menu',
title: t`Command menu`,
Icon: IconCommand,
vimeoId: '1185416775',
},
{
id: 'views',
title: t`Views`,
Icon: IconTable,
vimeoId: '1145648745',
},
{
id: 'dashboards',
title: t`Dashboards`,
Icon: IconLayoutDashboard,
vimeoId: '1185511768',
},
];
return (
<SubMenuTopBarContainer
@@ -100,8 +91,8 @@ export const SettingsLayout = () => {
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={customizeIllustrationLight}
darkSrc={customizeIllustrationDark}
lightSrc={coverLight}
darkSrc={coverDark}
instanceIdPrefix={SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX}
tabs={heroTabs}
playButtonAriaLabel={t`Watch customization demo`}
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

@@ -15,9 +15,8 @@ import { PermissionFlagType } from '~/generated-metadata/graphql';
import { SettingsWorkspaceMembersInviteTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersInviteTab';
import { SettingsWorkspaceMembersRolesTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab';
import { SettingsWorkspaceMembersTeamTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab';
// TODO: replace with members-specific illustrations + recordings when designed.
import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
import coverDark from '~/pages/settings/members/assets/cover-dark.png';
import coverLight from '~/pages/settings/members/assets/cover-light.png';
const MEMBERS_TAB_LIST_ID = 'members-tab-list';
@@ -74,8 +73,8 @@ export const SettingsWorkspaceMembers = () => {
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={placeholderHeroLight}
darkSrc={placeholderHeroDark}
lightSrc={coverLight}
darkSrc={coverDark}
instanceIdPrefix={SETTINGS_MEMBERS_HERO_INSTANCE_ID_PREFIX}
tabs={[
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -62,9 +62,8 @@ export class ApiKeyResolver {
}
}
// A long-lived API key is durable credential management: only a first-person
// session (ACCESS) may mint or alter one, so a short-lived derived token
// (PLAYGROUND) or an API key itself cannot escalate into another.
// Minting an API key requires an ACCESS token — derived PLAYGROUND tokens
// and API keys must not escalate into a long-lived credential.
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity)
async createApiKey(
@@ -55,8 +55,8 @@ export class ApiKeyController {
return this.apiKeyService.findById(id, workspace.id);
}
// Minting/altering a long-lived API key requires a first-person session
// (ACCESS); a derived PLAYGROUND token or an API key cannot escalate here.
// Minting an API key requires an ACCESS token — derived PLAYGROUND tokens
// and API keys must not escalate into a long-lived credential.
@UseGuards(RequireAccessTokenGuard)
@Post()
async create(
@@ -944,11 +944,7 @@ describe('JwtAuthStrategy', () => {
});
describe('PLAYGROUND token validation', () => {
// A PLAYGROUND token is access-shaped but must never impersonate. This is the
// same payload that, as an ACCESS token with isImpersonating set but without
// impersonation ids, throws 'Invalid or missing user workspace ID in
// impersonation token' above; as a PLAYGROUND token it must skip the
// impersonation path entirely and resolve to the first-person context.
// PLAYGROUND tokens are access-shaped but must never impersonate.
it('ignores isImpersonating and resolves first-person', async () => {
const validUserId = 'valid-user-id';
const validUserWorkspaceId = randomUUID();
@@ -7,9 +7,8 @@ export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
connectionProviderId: string;
userId: string;
userWorkspaceId: string;
// 'user' = the resulting credential is private to userWorkspaceId.
// 'workspace' = visible to anyone in the workspace.
// Named `visibility` to disambiguate from OAuth `scopes` on the row.
// 'user' scopes the credential to userWorkspaceId; 'workspace' makes it
// visible to anyone in the workspace.
visibility: 'user' | 'workspace';
// If set, the callback updates this existing connectedAccount row instead
// of creating a new one (used by the UI's "Reconnect" action).
@@ -20,6 +20,14 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
'Enable many-to-many relations through junction tables configuration',
},
},
{
key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED,
metadata: {
label: 'Settings Discovery Hero',
description:
'Show the per-page hero illustration + video walkthrough modal on settings pages',
},
},
...(process.env.CLOUDFLARE_API_KEY
? [
// {
@@ -237,6 +237,7 @@ describe('WorkspaceEntityManager', () => {
IS_EMAIL_GROUP_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -45,6 +45,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_SETTINGS_DISCOVERY_HERO_ENABLED,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};
@@ -33,9 +33,8 @@ describe('api key and webhooks permissions', () => {
});
});
// A non-session token (here API_KEY; PLAYGROUND shares the same access path)
// must not mint a durable API key, otherwise a short-lived token could
// escalate into a long-lived one. RequireAccessTokenGuard enforces this.
// Non-ACCESS tokens (API_KEY here, PLAYGROUND same path) must never mint
// an API key — enforced by RequireAccessTokenGuard.
it('should reject a non-ACCESS token even with API key permission', async () => {
const queryData = {
query: `
@@ -6,4 +6,5 @@ export enum FeatureFlagKey {
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
}