diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 8710553e800..114bc1f30a8 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -1,6 +1,9 @@ --- paths: - "apps/sim/app/workspace/*/settings/**" + - "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**" + - "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**" + - "apps/sim/components/{settings,permissions}/**" - "apps/sim/ee/**/components/**" --- @@ -20,7 +23,7 @@ Do NOT hand-roll any of these in a settings page — they are owned by the layou shell (fed through `SettingsPanel`): - `
` shell -- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`) +- the header bar — compose `PAGE_HEADER_BAR` (`@/components/page-header-bar`); never rewrite its padding - the scroll container (`min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]`) - the content column (`mx-auto … max-w-[48rem] … gap-7`) - a title block (`

` + `

`) @@ -55,21 +58,23 @@ return ( ## `SettingsPanel` props - `actions?: SettingsAction[]` — right-aligned header chips, **data only**: - `{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }`. + `{ id?, text, textTone?: 'error', icon?, variant?: 'primary'|'destructive', active?, + onSelect, onPrefetch?, disabled?, tooltip? }`. The shell renders each as a `Chip` — never pass JSX, a `

`, or `className` (the locked contract: it's structurally impossible to vibe-code a padding change). Multiple/conditional actions are a plain array (`[...(canManage ? [{…}] : []), …]`). Labels are **sentence case** (`Add override`, not `Add Override`). A disabled action that needs to explain itself sets - `tooltip` (the shell renders the hover tooltip, disabled chip included) — never - hand-roll a tooltip-wrapped chip in `aside`. Save/Discard pairs come from the - `saveDiscardActions()` helper (spread it into `actions`). Only a widget that - genuinely cannot be a chip (e.g. one needing hover-prefetch) goes in `aside`. + `tooltip` (the shell renders the hover tooltip, disabled chip included). An action + that wants to warm a route on hover sets `onPrefetch`; the shell wires it. A label + that flips while pending (`Delete` → `Deleting...`) sets a stable `id`, or the chip + remounts mid-action. Save/Discard pairs come from the `saveDiscardActions()` + helper (spread it into `actions`). - `back?: SettingsBackAction` (`{ text, icon?, onSelect }`) — left-aligned back chip for a **detail sub-view** (e.g. a selected MCP server, a permission group, a retention policy). Detail sub-views render through `SettingsPanel` like list pages — they do NOT hand-roll their own shell. -- `aside?: ReactNode` — escape hatch for the rare non-chip header widget. Keep it rare. +- `docsLink?: string` — renders the header's `Docs` `ChipLink`. - `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` — renders the canonical search field directly below the title. Pass `setSearchTerm` straight to `onChange`. Use this for a standalone search; if search shares a row @@ -82,15 +87,15 @@ return ( ## Title + description live in navigation metadata -`apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` is the single source -of truth. Every `NavigationItem` carries a one-line `description`; `SettingsPanel` -resolves both via `getSettingsSectionMeta(section)` and the +`apps/sim/components/settings/navigation.ts` is the single source of truth (the +`settings/navigation.ts` in the route tree is only a re-export shim). Every `NavigationItem` carries a one-line `description`; `SettingsPanel` +resolves both via `getSettingsSectionMeta(plane, section)` and the `SettingsSectionProvider` the settings shell wraps around the active section. Adding a new settings page: -1. Add the `SettingsSection` id + a `NavigationItem` (with `label` **and** - `description`) in `navigation.ts`. Keep descriptions verb-first, one line, +1. Add the section id to the `UnifiedSettingsSection` union + a `NavigationItem` + (with `label` **and** `description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line, ~40–55 chars, in the product voice (see `.claude/rules/constitution.md`). 2. Render the component inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`. @@ -107,19 +112,14 @@ token (if the pixel value matches one exactly) or a sign the page never migrated grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and `apps/sim/ee/**` to find stragglers. -For a two-line list row (title/value on top, a muted subtitle below — a name + -email, a tool name + description, a server name + status), the established -pairing is: +Watch `text-xs`: it is 11px here, so a "caption" written as `text-xs` is a pixel +short. See `sim-styling.md` for the full scale. -- **Title / row value**: `text-[var(--text-body)] text-sm` -- **Subtitle / muted description**: `text-[var(--text-muted)] text-caption` - -This is not a stylistic guess — it is the tokenized form of the literal-pixel -pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px] -text-[var(--text-muted)]`) already used for this exact row shape across -`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`, -`workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather -than inventing a new size pairing. +The two-line list row (title over a muted subtitle — a name + email, a tool name ++ description, a server name + status) is **not something you build**: it is +`SettingsResourceRow`, which owns the pairing +(`text-[var(--text-body)] text-sm` over `text-[var(--text-muted)] text-caption`). +See "The resource row" below. For a toggle row (a `Switch` with a title and optional description), use the emcn `Label` component for the title — never a hand-rolled `` — paired with @@ -145,21 +145,114 @@ independently-defined tokens (not interchangeable — they resolve to different colors) and both see legitimate use across settings pages; this rule only pins down the **row title/subtitle** shape above, not every text element on every page. +## The resource row + +**`SettingsResourceRow`** (`…/components/settings-resource-row`) is *the* list row +for every settings resource — and for skills, integrations, and the `ee/` surfaces +too. It owns the tile, the title/subtitle tokens, the row padding and bleed +(`-mx-2 … rounded-lg p-2`), the hit area, the focus ring, the navigation chevron, +and — on activatable rows only — the hover band. Never hand-roll any of it, and never wrap the row in your own +` + clickLabel={`Use template ${title}`} + navigable + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx index ea4fa847a2b..575a448fe2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx @@ -6,6 +6,11 @@ import { Check, Plus } from 'lucide-react' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { isSkillNameConflictError } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SuggestedSkill } from '@/blocks/types' import { useCreateSkill, useSkills } from '@/hooks/queries/skills' @@ -26,22 +31,23 @@ interface SkillRowProps { function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) { return ( -
- -
- {skill.name} - {skill.description} -
- {added ? ( - - Added - - ) : ( - - {pending ? 'Adding...' : 'Add'} - - )} -
+ } + title={skill.name} + description={skill.description} + trailing={ + added ? ( + + Added + + ) : ( + + {pending ? 'Adding...' : 'Add'} + + ) + } + /> ) } @@ -98,10 +104,8 @@ export function IntegrationSkillsSection({ } return ( -
- Skills -
-
+ +
{skills.map((skill, index) => ( ))}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx index 827cbe519c7..08dae05a2cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import { RESOURCE_LIST_GRID } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' interface IntegrationSectionProps { label: string @@ -6,19 +8,15 @@ interface IntegrationSectionProps { } /** - * Labeled section used throughout the integrations surface. Renders a small - * caption, a divider, and a responsive auto-fit grid for its children so the - * vertical rhythm stays consistent across the integrations list, the connected - * credentials list, and the integration detail page templates. + * Labeled section used throughout the integrations surface: the shared + * {@link SettingsSection} label/divider chrome wrapped around the shared + * responsive card grid, so the integrations list, the connected credentials + * list, and the integration detail templates cannot drift from settings. */ export function IntegrationSection({ label, children }: IntegrationSectionProps) { return ( -
- {label} -
-
- {children} -
-
+ +
{children}
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index e3b01070117..43a25c08ba1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -1,5 +1,9 @@ import type { ComponentType } from 'react' import { cn } from '@sim/emcn' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' import { getBlock } from '@/blocks' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -69,13 +73,11 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ if (!framed) { return ( -
-
- -
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx index 8009841fe31..f5363986d1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx @@ -1,7 +1,7 @@ 'use client' import { Chip } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' +import { ArrowRight } from '@sim/emcn/icons' import { useParams, useRouter } from 'next/navigation' import { isChatEnabled } from '@/lib/core/config/env-flags' import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index fa40014816c..45ffddc13bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -8,6 +8,7 @@ import { ChipInput, ChipLink, ChipTextarea, + cn, Send, toast, } from '@sim/emcn' @@ -27,11 +28,16 @@ import { UnsavedChangesModal, useCredentialDetailForm, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' import { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useCreateCredentialDraft, useDeleteWorkspaceCredential, @@ -217,7 +223,7 @@ export function ConnectedCredentialDetail({ if (credentialsLoading && !credential) { return ( -

Loading…

+ Loading…
) } @@ -225,7 +231,7 @@ export function ConnectedCredentialDetail({ if (!credential) { return ( -

Credential not found.

+ Credential not found.
) } @@ -241,7 +247,7 @@ export function ConnectedCredentialDetail({ display?.icon ? ( ) : ( -
+
{resolveProviderLabel(credential.providerId).slice(0, 1) || '?'} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 2f04ac50d33..c9a8aa539f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -2,7 +2,6 @@ import { type ComponentType, useCallback, useMemo, useRef } from 'react' import { - ArrowRight, ChevronDown, ChipInput, chipVariants, @@ -12,7 +11,6 @@ import { DropdownMenuTrigger, Search, } from '@sim/emcn' -import Link from 'next/link' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { @@ -34,18 +32,14 @@ import { integrationsParsers, integrationsUrlKeys, } from '@/app/workspace/[workspaceId]/integrations/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const -const LINK_ROW_CLASSES = - 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' -const LINK_ROW_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' -const LINK_ROW_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' -const LINK_ROW_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' - const FEATURED_INTEGRATIONS: readonly Integration[] = (() => { const bySlug = new Map(INTEGRATIONS.map((i) => [i.slug, i])) return FEATURED_SLUGS.map((slug) => bySlug.get(slug)).filter( @@ -85,14 +79,15 @@ function IntegrationItem({ icon: Icon, }: IntegrationItemProps) { return ( - - -
- {name} - {description && {description}} -
- - + } + title={name} + description={description || undefined} + href={`/workspace/${workspaceId}/integrations/${slug}`} + clickLabel={`Open ${name}`} + navigable + /> ) } @@ -122,14 +117,15 @@ interface ConnectedItemProps { function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) { return ( - - -
- {name} - {description} -
- - + } + title={name} + description={description} + href={href} + clickLabel={`Open ${name}`} + navigable + /> ) } @@ -361,11 +357,11 @@ export function Integrations() { ))} {showNoResults && ( -
+ {urlSearchTerm.trim() ? `No integrations found matching “${urlSearchTerm}”` : 'No integrations in this category'} -
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 5a2daaa70ca..e456d250d1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -302,8 +302,8 @@ export function Admin() { <>
- -

+ +

Default uses the configured Sim agent URL.

diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 2c7b1561383..6daf1e083cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -13,6 +13,10 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { @@ -198,24 +202,50 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {showsWorkspaceKeys && !searchTerm.trim() ? ( {workspaceKeys.length === 0 ? ( -
No workspace API keys yet
+ + No workspace API keys yet + ) : ( -
+
{workspaceKeys.map((key) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + canDelete={canManageWorkspaceKeys} + /> + } + /> + ))} +
+ )} + + ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( + +
+ {filteredWorkspaceKeys.map(({ key }) => ( + + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ { @@ -224,38 +254,8 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { }} canDelete={canManageWorkspaceKeys} /> -
- ))} -
- )} - - ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( - -
- {filteredWorkspaceKeys.map(({ key }) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - canDelete={canManageWorkspaceKeys} - /> -
+ } + /> ))}
@@ -263,38 +263,34 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( -
+
{filteredPersonalKeys.map(({ key }) => { const isConflict = conflictNames.has(key.name) return ( -
-
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - /> -
+
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + /> + } + /> {isConflict && ( -
+

Workspace API key with the same name overrides this. Rename your personal key to use it. -

+

)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 76c36f517f3..0505c9fb8f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -47,6 +47,7 @@ import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/compo import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useBillingUsageNotifications, @@ -637,7 +638,7 @@ export function Billing({ {invoice.description ?? ''} - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx index e154e12b593..5d1d78265e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx @@ -14,6 +14,8 @@ const { mockBridge, mockToast } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ + /** `password-detail` composes the shared tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), ArrowLeft: () => , Button: ({ children, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 0c301136264..90091fb5f00 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -8,6 +8,7 @@ import { ChipConfirmModal, ChipCopyInput, ChipInput, + cn, Duplicate, Eye, EyeOff, @@ -16,6 +17,11 @@ import { toast, } from '@sim/emcn' import { getDesktopBridge } from '@/lib/desktop' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -136,32 +142,27 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta >
-
- Site +
-
-
- {credential.icon ? ( - // A `data:` URL copied from the source browser at import - // time — never a network request, which would disclose - // which sites the user has passwords for. - - ) : ( - - )} -
+
+ {credential.icon ? ( + // A `data:` URL copied from the source browser at import + // time — never a network request, which would disclose + // which sites the user has passwords for. + + ) : ( + + )}
-
+
-
- Username + -
+ -
- Password + } /> -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index b38ba8b8149..b334e010c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -175,11 +175,14 @@ async function click(button: HTMLButtonElement) { }) } -/** Cards are buttons outside the header; each shows a site and a username. */ -const cards = () => - [...container.querySelectorAll('main > button, main div button')].filter( +/** Each card's hit area — a stretched overlay button owned by `SettingsResourceRow`. */ +const cardButtons = () => + [...container.querySelectorAll('main button[aria-label^="Open "]')].filter( (node) => !node.closest('header') - ) + ) as HTMLButtonElement[] + +/** The row wrapping each hit area; it carries the visible site and username. */ +const cards = () => cardButtons().map((button) => button.parentElement as HTMLElement) const bridge = () => mockBridge.current as ReturnType @@ -220,7 +223,7 @@ describe('PasswordsView', () => { it('opens the detail page for the card that was clicked', async () => { await render() - await click(cards()[1] as HTMLButtonElement) + await click(cardButtons()[1]) expect(container.querySelector('[aria-label="Password detail"]')?.textContent).toBe( 'https://fubo.tv' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx index aad358a1fba..afe1b8fe2e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -7,21 +7,18 @@ import type { BrowserImportError, BrowserImportProfile, } from '@sim/desktop-bridge' -import { ArrowLeft, ArrowRight, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' +import { ArrowLeft, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' import { getDesktopBridge } from '@/lib/desktop' import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_GRID, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -/** The integrations page's responsive card grid (see `integration-section.tsx`, `skills.tsx`). */ -const CARD_GRID = '-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5' -/** Card hit area; the row chrome inside it comes from {@link SettingsResourceRow}. */ -const CARD_CLASSES = - 'w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' - const IMPORT_ERROR_MESSAGES: Record = { 'unsupported-platform': 'Importing from another browser is only supported on macOS.', 'chrome-not-found': 'Could not find that browser profile.', @@ -193,32 +190,28 @@ export function PasswordsView({ credentials, onChange, onBack, onImported }: Pas ) : ( <> -
+
{filtered.map((credential) => ( - + clickLabel={`Open ${siteLabel(credential.origin)}`} + navigable + /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index 51d3bd630a8..a9d10d9a10d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -5,13 +5,13 @@ import { Button, Chip, ChipConfirmModal, + ChipInput, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, - cn, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -21,9 +21,11 @@ import { CHIP_FIELD_SHELL, } from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field' import { BYOKProviderKeysModal } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal' -import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' const logger = createLogger('BYOKKeyManager') @@ -308,31 +310,20 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { <>
{showSearch && ( -
- - setSearchTerm(e.target.value)} - disabled={isLoading} - className={cn(CHIP_FIELD_INPUT, 'disabled:cursor-not-allowed disabled:opacity-60')} - /> -
+ setSearchTerm(e.target.value)} + disabled={isLoading} + className='w-full' + /> )} {description &&

{description}

} - {isLoading ? ( -
- {providers.map((p) => ( - - ))} -
- ) : showNoResults ? ( + {isLoading ? null : showNoResults ? ( No providers found matching "{searchTerm}" @@ -346,13 +337,13 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { return ( -
{rows.map(renderRow)}
+
{rows.map(renderRow)}
) })}
) : ( -
{filteredProviders.map(renderRow)}
+
{filteredProviders.map(renderRow)}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx deleted file mode 100644 index 71d220b9c37..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Skeleton } from '@sim/emcn' - -/** - * Skeleton component for BYOK provider key items. - */ -export function BYOKKeySkeleton() { - return ( -
-
- -
- - -
-
-
- - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index c746dd62828..b7cd5655408 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from 'react' import { - Chip, ChipConfirmModal, ChipModal, ChipModalBody, @@ -15,9 +14,14 @@ import { import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type CopilotKey, @@ -132,30 +136,33 @@ export function Copilot() { {isLoading ? null : showEmptyState ? ( Click "Create API key" above to get started ) : ( -
+
{filteredKeys.map((key) => ( -
-
-
- - {key.name || 'Unnamed Key'} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

{key.displayKey}

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - > - Delete - -
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }, + }, + ]} + /> + } + /> ))} {showNoResults && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx index 88788ec0ea7..653bc066b94 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -2,15 +2,11 @@ import { useMemo, useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' -import { ArrowLeft, Wrench } from '@sim/emcn/icons' +import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { saveDiscardActions } from '@/components/settings/save-discard-actions' -import { ResourceTile } from '@/app/workspace/[workspaceId]/components' -import { - CredentialDetailHeading, - UnsavedChangesModal, -} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { CUSTOM_TOOL_DELETE_CONFIRM_TEXT, CustomToolCodeField, @@ -204,6 +200,11 @@ export function CustomToolDetail({ guard.guardBack(onBack) }} title={identity.name || tool?.title || 'New tool'} + description={ + identity.description || + tool?.schema.function.description || + 'Define the JSON schema your agents call, and the code that runs.' + } actions={[ ...(readOnly ? [] @@ -218,6 +219,7 @@ export function CustomToolDetail({ ...(tool && !readOnly ? [ { + id: 'delete', text: deleteTool.isPending ? 'Deleting...' : 'Delete', variant: 'destructive' as const, onSelect: () => setShowDeleteConfirm(true), @@ -228,16 +230,6 @@ export function CustomToolDetail({ ]} >
- } - title={identity.name || tool?.title || 'New tool'} - subtitle={ - identity.description || - tool?.schema.function.description || - 'Define the JSON schema your agents call, and the code that runs.' - } - /> - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load tools')} -

-
+ + {getErrorMessage(error, 'Failed to load tools')} + ) : isLoading ? null : showEmptyState ? ( {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} ) : ( -
+
{filteredTools.map((tool) => ( - + clickLabel={`Open ${tool.title || 'Unnamed Tool'}`} + navigable + /> ))} {showNoResults && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index d0c06152363..6fe4b1e29f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -15,7 +15,10 @@ import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/l import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' function getMounts(response: LocalFilesystemResponse): LocalFilesystemMount[] | null { @@ -297,7 +300,7 @@ export function Desktop() { No folder access granted. Chat can only read folders you add here. ) : ( -
+
{mounts.map((mount) => ( void revealFolder(mount)} clickLabel={`Show ${mount.name} in the file manager`} + badge={ + !mount.remembered ? ( + + Until app restarts + + ) : undefined + } trailing={ -
- {!mount.remembered && ( - - Until app restarts - - )} - setMountToForget(mount), - }, - ]} - /> -
+ setMountToForget(mount), + }, + ]} + /> } /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index df0a8c8dc1d..e68485d5739 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -2,8 +2,9 @@ import { useCallback, useMemo } from 'react' import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' import { formatRelativeTime } from '@sim/utils/formatting' -import { ArrowRight, Paperclip } from 'lucide-react' +import { Paperclip } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { @@ -12,6 +13,7 @@ import { inboxTaskUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/inbox/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import type { InboxTaskItem } from '@/hooks/queries/inbox' import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -119,7 +121,7 @@ export function InboxTaskList() { ) ) : ( -
+
{filteredTasks.map((task) => { const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received const isClickable = @@ -177,9 +179,7 @@ export function InboxTaskList() { )} {statusBadge.label} - {isClickable && ( - - )} + {isClickable && }
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 5558e23fb35..0c886ebf438 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -8,6 +8,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' @@ -25,9 +26,13 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { getRefreshActionState } from '@/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state' import { getServerToolsLabel } from '@/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' @@ -67,12 +72,10 @@ interface ServerListItemProps { canManage: boolean server: McpServer tools: McpTool[] - isDeleting: boolean isConnecting: boolean isLoadingTools?: boolean isRefreshing?: boolean discoveryError?: string | null - onRemove: () => void onViewDetails: () => void onAuthorize: () => void } @@ -81,12 +84,10 @@ function ServerListItem({ canManage, server, tools, - isDeleting, isConnecting, isLoadingTools = false, isRefreshing = false, discoveryError = null, - onRemove, onViewDetails, onAuthorize, }: ServerListItemProps) { @@ -110,56 +111,46 @@ function ServerListItem({ server.connectionStatus === 'disconnected' || showDiscoveryError + const serverName = server.name || 'Unnamed server' + // Transport rides on the description rather than beside the name — inside the + // row's truncating title a long name would clip it away entirely. + const statusText = isConnecting + ? 'Waiting for authorization...' + : isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : showDiscoveryError + ? discoveryError + : toolsLabel + return ( -
-
-
- - {server.name || 'Unnamed server'} + } + iconFilled + title={serverName} + description={ + <> + {`${transportLabel} · `} + {/* Only the status reddens — the transport is neutral metadata. */} + + {statusText} - ({transportLabel}) -
-

- {isConnecting - ? 'Waiting for authorization...' - : isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : showDiscoveryError - ? discoveryError - : toolsLabel} -

-
-
- {canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( + + } + onClick={onViewDetails} + clickLabel={`Open ${serverName}`} + navigable + trailing={ + canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' ? ( {isConnecting ? 'Reopen authorization' : 'Authorize'} - )} - -
-
+ ) : undefined + } + /> ) } @@ -247,6 +238,9 @@ export function MCP() { try { await deleteServerMutation.mutateAsync({ workspaceId, serverId }) + // Deleting from the detail view leaves a dead id in the URL — drop it so Back + // doesn't land on a server that no longer exists. + if (selectedServerId === serverId) handleBackToList() logger.info(`Removed MCP server: ${serverId}`) } catch (error) { logger.error('Failed to remove MCP server:', error) @@ -402,6 +396,28 @@ export function MCP() { const hasServers = servers && servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 + // Delete is reachable from both the list and the detail header, so the confirm + // modal has to render in whichever branch is mounted. + const deleteConfirmModal = canEdit ? ( + { + if (!open) setServerToDeleteId(null) + }} + srTitle='Delete MCP server' + title='Delete MCP server' + text={[ + 'Are you sure you want to delete ', + { + text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server', + bold: true, + }, + '? This action cannot be undone.', + ]} + confirm={{ label: 'Delete', onClick: confirmDeleteServer }} + /> + ) : null + if (selectedServer) { const { server, tools } = selectedServer const transportLabel = formatTransportLabel(server.transport || 'http') @@ -429,32 +445,31 @@ export function MCP() { text: 'Edit', onSelect: () => setEditingServerId(server.id), }, + { + id: 'delete', + text: deletingServers.has(server.id) ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => handleRemoveServer(server.id), + disabled: deletingServers.has(server.id), + }, ] : [] } >
-
- Server name -

{server.name || 'Unnamed server'}

-
+ {server.name || 'Unnamed server'} -
- Transport -

{transportLabel}

-
+ {transportLabel} {server.url && ( -
- URL -

{server.url}

-
+ + {server.url} + )} {server.connectionStatus !== 'connected' && ( -
- Status +

{getServerToolsLabel( [], @@ -463,12 +478,11 @@ export function MCP() { server.authType )}

-
+ )} {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( -
- Authentication +
-
+ )}
@@ -551,7 +565,7 @@ export function MCP() { {isExpanded && hasParams && (
-

+

Parameters

@@ -586,7 +600,7 @@ export function MCP() { )}
{paramDesc && ( -

+

{paramDesc}

)} @@ -628,6 +642,7 @@ export function MCP() { allowedMcpDomains={allowedMcpDomains} /> )} + {deleteConfirmModal} ) } @@ -655,11 +670,9 @@ export function MCP() { } > {listError ? ( -
-

- {getErrorMessage(listError, 'Failed to load MCP servers')} -

-
+ + {getErrorMessage(listError, 'Failed to load MCP servers')} + ) : serversLoading ? ( Loading... ) : !hasServers ? ( @@ -667,7 +680,7 @@ export function MCP() { {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'} ) : ( -
+
{filteredServers.map((server) => { if (!server?.id) return null const tools = toolsByServer[server.id] || [] @@ -682,7 +695,6 @@ export function MCP() { canManage={canEdit} server={server} tools={tools} - isDeleting={deletingServers.has(server.id)} isConnecting={connectingOauthServers.has(server.id)} isLoadingTools={isLoadingTools} isRefreshing={ @@ -692,7 +704,6 @@ export function MCP() { discoveryError={ serverToolsState?.error ? getErrorMessage(serverToolsState.error) : null } - onRemove={() => handleRemoveServer(server.id)} onViewDetails={() => handleViewDetails(server.id)} onAuthorize={() => startOauthForServer(server.id)} /> @@ -727,25 +738,7 @@ export function MCP() { /> )} - {canEdit && ( - { - if (!open) setServerToDeleteId(null) - }} - srTitle='Delete MCP server' - title='Delete MCP server' - text={[ - 'Are you sure you want to delete ', - { - text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server', - bold: true, - }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: confirmDeleteServer }} - /> - )} + {deleteConfirmModal} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index df8930fe4ac..3bf5fafbdad 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -22,7 +22,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' @@ -526,11 +529,9 @@ export function RecentlyDeleted() { /> {error ? ( -
-

- {toError(error).message || 'Failed to load deleted items'} -

-
+ + {toError(error).message || 'Failed to load deleted items'} + ) : isLoading ? null : filtered.length === 0 ? ( showNoResults ? ( @@ -540,7 +541,7 @@ export function RecentlyDeleted() { No deleted items ) ) : ( -
+
{filtered.map((resource) => { const isRestoring = restoringIds.has(resource.id) const isRestored = restoredItems.has(resource.id) @@ -561,22 +562,24 @@ export function RecentlyDeleted() { Deleted {formatDate(resource.deletedAt)} } + badge={ + canRestore && isRestored ? ( + + {PAUSED_AUTOMATION_TYPES.has(resource.type) + ? 'Restored \u00b7 schedules and webhooks stay paused' + : 'Restored'} + + ) : undefined + } trailing={ !canRestore ? null : isRestoring ? ( Restoring... ) : isRestored ? ( -
- - {PAUSED_AUTOMATION_TYPES.has(resource.type) - ? 'Restored \u00b7 schedules and webhooks stay paused' - : 'Restored'} - - handleView(resource)}> - View - -
+ handleView(resource)}> + View + ) : ( void handleRestore(resource)}> Restore diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx index c6887f23277..c5cb959405f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx @@ -9,6 +9,7 @@ import { type SandboxDraft, type SandboxLanguage, } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import type { Sandbox } from '@/hooks/queries/sandboxes' @@ -41,8 +42,7 @@ export function SandboxEditor({
-
- Name + onChange({ ...draft, name: event.target.value })} @@ -51,9 +51,8 @@ export function SandboxEditor({ maxLength={64} autoComplete='off' /> -
-
- Language + + onChange({ ...draft, language: language as SandboxLanguage })} @@ -63,7 +62,7 @@ export function SandboxEditor({ }))} disabled={disabled} /> -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx index 45c2e437303..ed393de09a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { toast } from '@sim/emcn' +import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' @@ -12,7 +12,6 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SandboxEditor, SandboxStatus, @@ -28,12 +27,15 @@ import { SANDBOX_UPGRADE_DESCRIPTION, SANDBOX_UPGRADE_TITLE, type SandboxDraft, + sandboxDeleteConfirmText, toSubmittedLines, } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -66,6 +68,7 @@ export function Sandboxes() { const [draft, setDraft] = useState(null) const [issues, setIssues] = useState([]) const [isCreating, setIsCreating] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) // The draft belongs to whatever was open when it was typed. Browser Back // clears `selectedId` without going through `closeEditor`, so without this the @@ -77,6 +80,10 @@ export function Sandboxes() { setDraft(null) setIssues([]) } + // The confirmation belongs to the sandbox that opened it. Browser Back unmounts + // the modal without closing it, so leaving this set would re-open it against + // whichever sandbox is selected next — and delete that one instead. + setShowDeleteConfirm(false) // Creating and having one open are mutually exclusive, and history can land on // a sandbox while create mode is still set — Forward after starting a new one. // Leaving both on renders an empty "New sandbox" form whose Delete still points @@ -140,6 +147,7 @@ export function Sandboxes() { const handleDelete = useCallback( async (sandbox: Sandbox) => { + setShowDeleteConfirm(false) try { await deleteSandbox.mutateAsync({ workspaceId, sandboxId: sandbox.id }) if (selectedId === sandbox.id) closeEditor() @@ -207,9 +215,10 @@ export function Sandboxes() { ...(selected && canAdmin ? [ { - text: 'Delete', - textTone: 'error' as const, - onSelect: () => void handleDelete(selected), + id: 'delete', + text: deleteSandbox.isPending ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => setShowDeleteConfirm(true), disabled: deleteSandbox.isPending, }, ] @@ -225,6 +234,17 @@ export function Sandboxes() { /> + {selected && ( + void handleDelete(selected) }} + /> + )} + - - {filtered.length === 0 ? ( - - {searchTerm - ? 'No sandboxes match your search.' - : 'No sandboxes yet. Create one to let Function blocks import packages.'} - - ) : ( -
- {filtered.map((sandbox) => ( - } - title={ - - } - description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} - trailing={ - canAdmin ? ( - void setSelectedId(sandbox.id) }, - { - label: 'Delete', - destructive: true, - onSelect: () => void handleDelete(sandbox), - }, - ]} - /> - ) : undefined - } - /> - ))} -
- )} -
+ {filtered.length === 0 ? ( + + {searchTerm + ? 'No sandboxes match your search.' + : 'No sandboxes yet. Create one to let Function blocks import packages.'} + + ) : ( +
+ {filtered.map((sandbox) => ( + } + iconFilled + title={sandbox.name} + description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} + onClick={() => void setSelectedId(sandbox.id)} + clickLabel={`Open ${sandbox.name}`} + navigable + /> + ))} +
+ )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts index ae5c909621e..8ea2b721667 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts @@ -7,6 +7,20 @@ export const SANDBOX_UPGRADE_TITLE = 'Sandboxes require an active Max plan' export const SANDBOX_UPGRADE_DESCRIPTION = 'Upgrade to Max and ensure billing is active to install Python or npm packages that your Function blocks can import.' +/** Delete-confirmation copy, matching the custom tool detail's wording. Names the + * sandbox so the dialog is self-evidently about the one you opened it from. */ +export function sandboxDeleteConfirmText(name: string) { + return [ + 'This will permanently delete ', + { text: name, bold: true }, + { + text: ' and remove it from any Function blocks that are using it.', + error: true, + }, + ' This action cannot be undone.', + ] +} + /** Ordered to match the Function block's own `language` dropdown. */ export const LANGUAGE_OPTIONS = [ { label: 'JavaScript', value: 'javascript' }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 4b6d58ee8ab..2ad13f3d873 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -19,6 +19,7 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' @@ -999,9 +1000,7 @@ export function SecretsManager() { {(!searchTerm.trim() || filteredWorkspaceEntries.length > 0 || filteredNewWorkspaceRows.length > 0) && ( -
- Workspace -
+
{(searchTerm.trim() ? filteredWorkspaceEntries @@ -1044,13 +1043,11 @@ export function SecretsManager() { /> ))}
-
+ )} {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( -
- Personal -
+
{filteredEnvVars.map(({ envVar, originalIndex }) => (
@@ -1058,7 +1055,7 @@ export function SecretsManager() {
))}
-
+ )} {searchTerm.trim() && filteredEnvVars.length === 0 && diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx index c63457a65d6..6e1cc077ce4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx @@ -9,6 +9,8 @@ interface SettingsEmptyStateProps { * matched nothing. Defaults to `fill`. */ variant?: 'fill' | 'inline' + /** Renders the message in the error tone, for a failed load. */ + tone?: 'muted' | 'error' } /** @@ -16,11 +18,16 @@ interface SettingsEmptyStateProps { * "no results", and entitlement/loading gates. Centralizes the text token and * spacing so every settings page reads identically. */ -export function SettingsEmptyState({ children, variant = 'fill' }: SettingsEmptyStateProps) { +export function SettingsEmptyState({ + children, + variant = 'fill', + tone = 'muted', +}: SettingsEmptyStateProps) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts new file mode 100644 index 00000000000..2f151bb85c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts @@ -0,0 +1 @@ +export { SettingsField } from './settings-field' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx new file mode 100644 index 00000000000..3a8072eb3df --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface SettingsFieldProps { + label: ReactNode + /** Wraps long unbroken values (a URL, a key) instead of overflowing. */ + breakAll?: boolean + children: ReactNode +} + +/** + * A read-only label/value pair inside a settings detail body: a muted caption + * over the value. Single source for that pairing — before this, the same field + * was hand-rolled with three different label sizes and three different gaps. + * + * Renders the value paragraph itself, so callers never restate its type tokens. + * Pass a node instead of text only when the value is a control (a chip, a link). + */ +export function SettingsField({ label, breakAll = false, children }: SettingsFieldProps) { + return ( +
+ {label} + {typeof children === 'string' ? ( +

{children}

+ ) : ( + children + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts index da29f86f11c..335ecd0a0cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts @@ -1 +1,6 @@ -export { SettingsResourceRow } from './settings-resource-row' +export { + RESOURCE_LIST_GRID, + RESOURCE_LIST_STACK, + RESOURCE_ROW_ARROW_CLASSES, + SettingsResourceRow, +} from './settings-resource-row' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index d5481416753..01f354c27b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -1,8 +1,11 @@ -import type { ReactNode } from 'react' +import { type ReactNode, useId } from 'react' import { cn } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import Link from 'next/link' import { RESOURCE_TILE_BASE, RESOURCE_TILE_FILL, + RESOURCE_TILE_PLAIN, } from '@/app/workspace/[workspaceId]/components/resource-tile' /** @@ -16,15 +19,20 @@ import { * contains to 20px, so callers pass their raw icon node without pre-sizing it. */ interface SettingsResourceRowProps { - /** Icon node centered in the tile; a `` is normalized to 20px, an `` to 20px (or the full tile when `iconFill`). */ - icon: ReactNode + /** + * Icon node centered in the tile; a `` is normalized to 20px, an `` to + * 20px (or the full tile when `iconFill`). Omit it for rows whose resource has no + * identity glyph (an API key, a permission group) — the row then leads with text. + */ + icon?: ReactNode /** * Icon chrome. `tile` (default) is the bordered 36px tile for brand/logo and * resource icons; `plain` drops the tile for a bare 14px glyph in * `--text-icon`, for rows whose icon is a type marker rather than an identity - * (e.g. a folder on disk). + * (e.g. a folder on disk); `custom` renders `icon` verbatim, for callers that + * must supply their own tile (e.g. the brand-tinted `IntegrationTile`). */ - iconVariant?: 'tile' | 'plain' + iconVariant?: 'tile' | 'plain' | 'custom' /** * Let an image icon fill the tile edge-to-edge instead of clamping to 20px. * Use for uploaded image/logo icons (e.g. custom blocks); glyph ``s still @@ -41,20 +49,72 @@ interface SettingsResourceRowProps { /** Secondary muted line — truncates. */ description?: ReactNode /** - * Trailing element pinned to the row's end (chips, actions menu, status). The row - * keeps it at its natural size — callers never need their own `flex-shrink-0`. + * Interactive controls pinned to the row's end (chips, actions menu). These sit + * ABOVE the row's own hit area, so their clicks are theirs. The row keeps them at + * their natural size — callers never need their own `flex-shrink-0`. + * + * Decorative trailing content (a status badge, a tag) belongs in {@link badge}: + * anything placed here swallows clicks meant for the row. */ trailing?: ReactNode /** - * Makes the icon + text cluster activatable. `trailing` stays a sibling, so - * its own controls keep working — never nest an interactive `trailing` inside - * the row's own hit area. + * Decorative trailing content — a status badge or tag. Rendered before + * {@link trailing} and made click-through, so it never turns the row's right + * edge into a dead zone. + */ + badge?: ReactNode + /** + * Makes the whole row activatable via a stretched overlay button. `trailing` + * stacks above it, so interactive trailing controls (menus, chips) keep + * working — never nest an interactive `trailing` inside a caller-supplied + * wrapper `
- {icon} -
+ {icon == null ? null : iconVariant === 'custom' ? ( + icon + ) : ( +
+ {icon} +
+ )}
{title} {description != null && ( - {description} + + {description} + )}
) - const clusterClass = cn('flex min-w-0 items-center', isTile ? 'gap-2.5' : 'gap-2') + const clusterClass = cn( + 'flex min-w-0 items-center', + iconVariant === 'plain' ? 'gap-2' : 'gap-2.5' + ) + const hasEnd = badge != null || trailing != null || navigable + // Decoration and the chevron stay click-through so the row's right edge never + // becomes a dead zone; only `trailing` takes pointer events back. + const end = hasEnd ? ( +
+ {badge} + {trailing != null &&
{trailing}
} + {navigable && } +
+ ) : null + + // Row geometry is identical whether or not the row is activatable, so a list + // mixing clickable and static rows keeps one height and one inset. + const rowClass = cn('flex items-center justify-between gap-2.5', !flush && '-mx-2 rounded-lg p-2') + if (!onClick && !href) { + return ( +
+
{cluster}
+ {end} +
+ ) + } + + // The ring renders on the stretched overlay, which is inset-0 over the row — so a + // keyboard focus outline traces the visible row even though the control is empty. + const overlayClass = + 'absolute inset-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]' + + // The hit area is a stretched overlay rather than a wrapper around the cluster: + // it lets the hover band span the full row (matching every hand-rolled settings + // list) while `trailing` — which may hold its own buttons — stacks above it. return ( -
- {onClick ? ( +
+ {href ? ( + + ) : ( - ) : ( -
{cluster}
+ aria-describedby={description != null ? describedById : undefined} + className={overlayClass} + /> )} - {trailing ?
{trailing}
: null} +
{cluster}
+ {end}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx index 18fa43c9315..6e80f8b985e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' interface SettingsSectionProps { - label: string + label: ReactNode /** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */ headerAccessory?: ReactNode /** Optional control pinned to the far right of the header row (e.g. a Select All chip). */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx index d5063475eee..2417477fb83 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx @@ -123,7 +123,9 @@ export function CreateWorkflowMcpServerModal({ Public {formData.isPublic && ( - No authentication required + + No authentication required + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 69fed161b7e..1a7accd3634 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -27,6 +27,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { Check, Clipboard, Plus, Server } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { getBaseUrl } from '@/lib/core/utils/urls' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -39,8 +40,13 @@ import { import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components' import { useApiKeys } from '@/hooks/queries/api-keys' @@ -65,11 +71,22 @@ interface ServerDetailViewProps { workspaceId: string serverId: string onBack: () => void + /** Opens the parent's delete confirmation — the modal lives with the mutation. + * Absent until the parent's list resolves, so a deep link never shows an inert Delete. */ + onDelete?: () => void + isDeleting: boolean } type McpClientType = 'sim' | 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode' -function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDetailViewProps) { +function ServerDetailView({ + canManage, + workspaceId, + serverId, + onBack, + onDelete, + isDeleting, +}: ServerDetailViewProps) { const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId) const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = useDeployedWorkflows(workspaceId) @@ -363,11 +380,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe if (error || !data) { return ( -
-

- Failed to load server details -

-
+ Failed to load server details
) } @@ -393,6 +406,17 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe ? 'All deployed workflows have been added to this server.' : undefined, }, + ...(onDelete + ? [ + { + id: 'delete', + text: isDeleting ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: onDelete, + disabled: isDeleting, + }, + ] + : []), ] : [] } @@ -410,25 +434,20 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
{activeServerTab === 'workflows' && (
- Workflows - {tools.length === 0 ? (

No workflows added yet. Click "Add Workflow" to add a deployed workflow.

) : ( -
+
{tools.map((tool) => ( -
-
- {tool.toolName} -

- {tool.toolDescription || 'No description'} -

-
- {canManage && ( -
+ -
- )} -
+ ) : undefined + } + /> ))}
)} {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

+

Deploy a workflow first to add it to this server.

)} @@ -459,43 +478,24 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe {activeServerTab === 'details' && (
-
- - Server Name - -

{server.name}

-
-
- - Transport - -

Streamable-HTTP

-
-
- Access -

- {server.isPublic ? 'Public' : 'API Key'} -

-
+ {server.name} + Streamable-HTTP + + {server.isPublic ? 'Public' : 'API Key'} +
{server.description?.trim() && ( -
- - Description - -

{server.description}

-
+ {server.description} )} -
- URL -

{mcpServerUrl}

-
+ + {mcpServerUrl} +
- + MCP Client
@@ -563,7 +563,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )} {addToWorkspaceMutation.isError && ( -

+

{addToWorkspaceMutation.error?.message || 'Failed to add server'}

)} @@ -609,7 +609,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )}
{!server.isPublic && ( -

+

Replace $SIM_API_KEY with your API key {canManage && ( <> @@ -632,244 +632,235 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe

+ canManage && ( + !open && setToolToDelete(null)} + srTitle='Remove Workflow' + title='Remove Workflow' + text={[ + 'Are you sure you want to remove ', + { text: toolToDelete?.toolName ?? 'this workflow', bold: true }, + ' from this server? The workflow will remain deployed and can be added back later.', + ]} + confirm={{ + label: 'Remove', + onClick: handleDeleteTool, + pending: deleteToolMutation.isPending, + pendingLabel: 'Removing...', + }} + /> + )canManage && ( + { + if (!open) { + setToolToView(null) + setEditingDescription('') + setEditingParameterDescriptions({}) + } + }} + srTitle={toolToView?.toolName ?? 'Edit Tool'} + > + setToolToView(null)}> + {toolToView?.toolName} + + + - {canManage && ( - !open && setToolToDelete(null)} - srTitle='Remove Workflow' - title='Remove Workflow' - text={[ - 'Are you sure you want to remove ', - { text: toolToDelete?.toolName ?? 'this workflow', bold: true }, - ' from this server? The workflow will remain deployed and can be added back later.', - ]} - confirm={{ - label: 'Remove', - onClick: handleDeleteTool, - pending: deleteToolMutation.isPending, - pendingLabel: 'Removing...', - }} - /> - )} - - {canManage && ( - { - if (!open) { - setToolToView(null) - setEditingDescription('') - setEditingParameterDescriptions({}) - } - }} - srTitle={toolToView?.toolName ?? 'Edit Tool'} - > - setToolToView(null)}> - {toolToView?.toolName} - - - - - - {(() => { - const schema = toolToView?.parameterSchema as - | { properties?: Record } - | undefined - const properties = schema?.properties - const hasParams = properties && Object.keys(properties).length > 0 - return hasParams ? ( -
- {Object.entries(properties).map(([name, prop]) => ( -
-
-
- - {name} - - - {prop.type || 'any'} - -
+ + {(() => { + const schema = toolToView?.parameterSchema as + | { properties?: Record } + | undefined + const properties = schema?.properties + const hasParams = properties && Object.keys(properties).length > 0 + return hasParams ? ( +
+ {Object.entries(properties).map(([name, prop]) => ( +
+
+
+ + {name} + + + {prop.type || 'any'} +
-
-
- - - setEditingParameterDescriptions((prev) => ({ - ...prev, - [name]: e.target.value, - })) - } - placeholder={`Enter description for ${name}`} - /> -
+
+
+
+ + + setEditingParameterDescriptions((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + placeholder={`Enter description for ${name}`} + />
- ))} -
- ) : ( -

- No inputs configured for this workflow. -

- ) - })()} - - - setToolToView(null)} - primaryAction={{ - label: updateToolMutation.isPending ? 'Saving...' : 'Save', - onClick: handleSaveToolEdit, - disabled: isSaveToolDisabled, - }} - /> - - )} - - {canManage && ( - { - if (!open) { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - } +
+ ))} +
+ ) : ( +

+ No inputs configured for this workflow. +

+ ) + })()} +
+ + setToolToView(null)} + primaryAction={{ + label: updateToolMutation.isPending ? 'Saving...' : 'Save', + onClick: handleSaveToolEdit, + disabled: isSaveToolDisabled, }} - srTitle='Add Workflow' - > - { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - }} - > - Add Workflow - - -

- Select a deployed workflow to add to this MCP server. The workflow will be available - as a tool. -

- - setSelectedWorkflowId(value)} - placeholder='Select a workflow...' - searchable - searchPlaceholder='Search workflows...' - disabled={addToolMutation.isPending} - fullWidth - dropdownWidth='trigger' - align='start' - displayLabel={selectedWorkflow?.name} - /> - - - {addToolMutation.isError - ? addToolMutation.error?.message || 'Failed to add workflow' - : null} - -
- { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - }} - primaryAction={{ - label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow', - onClick: handleAddWorkflow, - disabled: !selectedWorkflowId || addToolMutation.isPending, - }} - /> - - )} - - {canManage && ( - { - if (!open) { - setShowEditServer(false) - } + /> + + )canManage && ( + { + if (!open) { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) + } + }} + srTitle='Add Workflow' + > + { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) }} - srTitle='Edit Server' > - setShowEditServer(false)}>Edit Server - - - + +

+ Select a deployed workflow to add to this MCP server. The workflow will be available as + a tool. +

+ + setSelectedWorkflowId(value)} + placeholder='Select a workflow...' + searchable + searchPlaceholder='Search workflows...' + disabled={addToolMutation.isPending} + fullWidth + dropdownWidth='trigger' + align='start' + displayLabel={selectedWorkflow?.name} /> - -
- setEditServerIsPublic(value === 'public')} - > - API Key - Public - -

- {editServerIsPublic - ? 'Anyone with the URL can call this server without authentication' - : 'Requests must include your Sim API key in the X-API-Key header'} -

-
-
-
- setShowEditServer(false)} - primaryAction={{ - label: updateServerMutation.isPending ? 'Saving...' : 'Save', - onClick: handleSaveServerEdit, - disabled: - !editServerName.trim() || - updateServerMutation.isPending || - (editServerName === server.name && - editServerDescription === (server.description || '') && - editServerIsPublic === server.isPublic), - }} +
+ + {addToolMutation.isError + ? addToolMutation.error?.message || 'Failed to add workflow' + : null} + +
+ { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) + }} + primaryAction={{ + label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow', + onClick: handleAddWorkflow, + disabled: !selectedWorkflowId || addToolMutation.isPending, + }} + /> +
+ )canManage && ( + { + if (!open) { + setShowEditServer(false) + } + }} + srTitle='Edit Server' + > + setShowEditServer(false)}>Edit Server + + - - )} - - {canManage && ( - + +
+ setEditServerIsPublic(value === 'public')} + > + API Key + Public + +

+ {editServerIsPublic + ? 'Anyone with the URL can call this server without authentication' + : 'Requests must include your Sim API key in the X-API-Key header'} +

+
+
+ + setShowEditServer(false)} + primaryAction={{ + label: updateServerMutation.isPending ? 'Saving...' : 'Save', + onClick: handleSaveServerEdit, + disabled: + !editServerName.trim() || + updateServerMutation.isPending || + (editServerName === server.name && + editServerDescription === (server.description || '') && + editServerIsPublic === server.isPublic), + }} /> - )} + + )canManage && ( + + ) ) } @@ -926,6 +917,12 @@ export function WorkflowMcpServers() { workspaceId, serverId: serverToDelete.id, }) + // Deleting from the detail view leaves a dead id in the URL; on reload the + // detail branch mounts against a server that no longer exists. + if (selectedServerId === serverToDelete.id) { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + } } catch (err) { logger.error('Failed to delete server:', err) } finally { @@ -951,17 +948,40 @@ export function WorkflowMcpServers() { const selectedServerResolves = selectedServerId !== null && (isLoading || servers.some((s) => s.id === selectedServerId)) + // Delete is reachable from both the list and the detail header, so the confirm + // modal has to render in whichever branch is mounted. + const deleteConfirmModal = canAdmin ? ( + !open && setServerToDelete(null)} + srTitle='Delete MCP Server' + title='Delete MCP Server' + text={[ + 'Are you sure you want to delete ', + { text: serverToDelete?.name ?? 'this server', bold: true }, + '? This action cannot be undone.', + ]} + confirm={{ label: 'Delete', onClick: handleDeleteServer }} + /> + ) : null + if (selectedServerId && selectedServerResolves) { + const selectedServer = servers.find((s) => s.id === selectedServerId) return ( - { - void setServerTab(null, { history: 'replace' }) - void setSelectedServerId(null, { history: 'replace' }) - }} - /> + <> + { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + }} + onDelete={selectedServer ? () => setServerToDelete(selectedServer) : undefined} + isDeleting={deletingServers.has(selectedServerId)} + /> + {deleteConfirmModal} + ) } @@ -989,62 +1009,42 @@ export function WorkflowMcpServers() { >
{error ? ( -
-

- {getErrorMessage(error, 'Failed to load MCP servers')} -

-
+ + {getErrorMessage(error, 'Failed to load MCP servers')} + ) : isLoading ? null : !hasServers ? ( {canAdmin ? 'Click "Add server" above to get started' : 'No MCP servers configured'} ) : ( -
+
{filteredServers.map((server) => { const count = server.toolCount || 0 const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}` - const isDeleting = deletingServers.has(server.id) return ( -
-
-
- - {server.name} - - {server.isPublic && ( - - Public - - )} -
-

{toolsLabel}

-
-
- { - // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. - void setServerTab(null) - void setSelectedServerId(server.id) - }, - }, - ...(canAdmin - ? [ - { - label: 'Delete', - destructive: true, - disabled: isDeleting, - onSelect: () => setServerToDelete(server), - }, - ] - : []), - ]} - /> -
-
+ } + iconFilled + title={server.name} + description={toolsLabel} + onClick={() => { + // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. + void setServerTab(null) + void setSelectedServerId(server.id) + }} + clickLabel={`Open ${server.name}`} + navigable + // The badge sits at the row's end, not beside the name — the + // title truncates, so a long name would clip it out of view. + badge={ + server.isPublic ? ( + + Public + + ) : undefined + } + /> ) })} {showNoResults && ( @@ -1066,20 +1066,7 @@ export function WorkflowMcpServers() { /> )} - {canAdmin && ( - !open && setServerToDelete(null)} - srTitle='Delete MCP Server' - title='Delete MCP Server' - text={[ - 'Are you sure you want to delete ', - { text: serverToDelete?.name ?? 'this server', bold: true }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: handleDeleteServer }} - /> - )} + {deleteConfirmModal} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index b1412d0d7f2..985b35649fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -4,18 +4,19 @@ import { useState } from 'react' import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn' import { ArrowLeft, Key } from '@sim/emcn/icons' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' import { AddPeopleModal, CredentialDetailHeading, CredentialDetailLayout, CredentialMembersSection, - DetailIconTile, DetailSection, UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useWorkspaceCredential } from '@/hooks/queries/credentials' interface SecretDetailProps { @@ -65,7 +66,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { if (isPending && !credential) { return ( -

Loading…

+ Loading…
) } @@ -73,7 +74,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { if (!credential) { return ( -

Secret not found.

+ Secret not found.
) } @@ -82,7 +83,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { <> } + leading={} title={credential.envKey || credential.displayName} subtitle={ isPersonal diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index 9c37ba94fdb..8fe37dcfe52 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -15,6 +15,7 @@ import { UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card' import { type SkillFieldErrors, @@ -199,7 +200,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { if ((skillsLoading || deleteSkill.isPending || deleteSkill.isSuccess) && !skill) { return ( -

Loading…

+ Loading…
) } @@ -207,7 +208,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { if (!skill) { return ( -

Skill not found.

+ Skill not found.
) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 2f2dd9ad2ff..b671fb1e84d 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -3,12 +3,18 @@ import { useEffect, useRef } from 'react' import { Chip, ChipInput, Search } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_GRID, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { skillIdParam, skillIdUrlKeys, @@ -20,48 +26,6 @@ import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const SKILLS_LABEL = 'Skills' -interface SkillItemProps { - name: string - description: string - onClick: () => void -} - -function SkillItem({ name, description, onClick }: SkillItemProps) { - return ( - - ) -} - -interface SkillSectionProps { - label: string - children: React.ReactNode -} - -function SkillSection({ label, children }: SkillSectionProps) { - return ( -
- {label} -
-
- {children} -
-
- ) -} - export function Skills() { const params = useParams() const router = useRouter() @@ -132,30 +96,36 @@ export function Skills() { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} disabled={isLoading} - className='flex-1' + className='min-w-0 flex-1' />
{error ? ( -
+ {getErrorMessage(error, 'Failed to load skills')} -
+ ) : filteredSkills.length > 0 ? ( - - {filteredSkills.map((s) => ( - router.push(`${skillsHref}/${s.id}`)} - /> - ))} - + +
+ {filteredSkills.map((s) => ( + } + title={s.name} + description={s.description || undefined} + onClick={() => router.push(`${skillsHref}/${s.id}`)} + clickLabel={`Open ${s.name}`} + navigable + /> + ))} +
+
) : showNoResults ? ( -
+ No skills found matching “{searchTerm}” -
+ ) : null}
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx index 808be0af4a0..a8b6cbeae41 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx @@ -1,5 +1,6 @@ 'use client' import { Check, ChipTag, Credit, chipVariants, cn, Info, RefreshCw } from '@sim/emcn' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' /** * Props for {@link UpgradePlanCard}. @@ -125,10 +126,7 @@ export function UpgradePlanCard({ )}
- {/* Section header + divider matching integrations/skills separator language */} -
- {segmentLabel} -
+
    {features.map((feature) => (
  • @@ -137,7 +135,7 @@ export function UpgradePlanCard({
  • ))}
-
+ ) } diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index ce4330347b1..003ba3eb514 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -14,7 +14,7 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' @@ -31,6 +31,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { GroupDetail } from '@/ee/access-control/components/group-detail' @@ -252,37 +256,27 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon No groups found matching "{searchTerm}" ) : ( -
+
{filteredGroups.map((group) => ( - + clickLabel={`Open ${group.name}`} + navigable + /> ))}
)} diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 5e47ebee4d2..97e289fb7ab 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -436,9 +436,9 @@ function AddMembersModal({
{filteredMembers.length === 0 ? ( -

+ No members found matching "{searchTerm}" -

+ ) : (
{filteredMembers.map((member) => { @@ -451,7 +451,7 @@ function AddMembersModal({ key={member.userId} type='button' onClick={() => handleToggleMember(member.userId)} - className='flex items-center gap-2.5 rounded-sm p-2 text-left hover-hover:bg-[var(--surface-active)]' + className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' > diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index 50d0b7ebceb..428b433da5f 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { ChipTag } from '@sim/emcn' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -13,7 +13,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' @@ -126,30 +129,21 @@ export function CustomBlocks() { No blocks found matching "{searchTerm}" ) : ( -
+
{filtered.map((cb) => { const Icon = getCustomBlockIcon(cb.iconUrl, fallbackIconUrl) return ( - + icon={} + iconFill + title={cb.name} + description={cb.description || undefined} + onClick={canAdmin ? () => void setSelectedBlockId(cb.id) : undefined} + clickLabel={`Open ${cb.name}`} + navigable={canAdmin} + badge={!cb.enabled ? Disabled : undefined} + /> ) })}
diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index 659c758ffb3..9a0235acdf3 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { ChipTag } from '@sim/emcn' -import { ArrowRight, Database, Plus } from '@sim/emcn/icons' +import { Database, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' import { @@ -12,7 +12,10 @@ import { import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { DataDrainCreate } from '@/ee/data-drains/components/data-drain-create' import { DataDrainDetail } from '@/ee/data-drains/components/data-drain-detail' @@ -105,42 +108,32 @@ export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) }} > {error ? ( -
-

- {getErrorMessage(error, "Couldn't load data drains")} -

-
+ + {getErrorMessage(error, "Couldn't load data drains")} + ) : isPending ? null : drains && drains.length > 0 ? ( -
+
{filteredDrains.map((drain) => ( - + clickLabel={`Open ${drain.name}`} + navigable + badge={!drain.enabled ? Disabled : undefined} + /> ))} {filteredDrains.length === 0 && ( diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 8359777bd59..8076adbd14b 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -18,7 +18,7 @@ import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' @@ -45,6 +45,10 @@ import { import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { @@ -994,42 +998,24 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe ]} > -
- + clickLabel='Open organization retention policy' + navigable + /> {overrideWorkspaceIds.map((workspaceId) => ( - + clickLabel={`Open ${workspaceName(workspaceId)} retention override`} + navigable + /> ))}
diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index c4e8ac53fe1..0af36c8de01 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -137,7 +137,7 @@ export default function SSOAuth({ identifier }: SSOAuthProps) { )} /> {showEmailValidationError && emailErrors.length > 0 && ( -
+
{emailErrors.map((error) => (

{error}

))} diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 638a004e216..1c41e429e3e 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -170,7 +170,7 @@ export default function SSOForm() { )} /> {showEmailValidationError && emailErrors.length > 0 && ( -
+
{emailErrors.map((error) => (

{error}

))} diff --git a/apps/sim/ee/sso/components/verified-domains-section.tsx b/apps/sim/ee/sso/components/verified-domains-section.tsx index e069fd16d53..bf914a9e005 100644 --- a/apps/sim/ee/sso/components/verified-domains-section.tsx +++ b/apps/sim/ee/sso/components/verified-domains-section.tsx @@ -46,16 +46,16 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { icon={} title={domain.domain} description={isVerified ? 'Ownership verified' : 'Awaiting DNS verification'} + badge={ + + {isVerified ? 'Verified' : 'Pending'} + + } trailing={ -
- - {isVerified ? 'Verified' : 'Pending'} - - onRemove(domain), destructive: true }]} - /> -
+ onRemove(domain), destructive: true }]} + /> } /> diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index e7d7a7d6131..02e22593e73 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -12,7 +12,7 @@ import { Label, Tooltip, } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' +import { ArrowRight } from '@sim/emcn/icons' import type { ForkCopyableUnmapped, ForkDependentReconfig,