From 80a01f027bcaf87e0c8e470c9b257e1e3dde844d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 2 Aug 2026 20:50:39 -0700 Subject: [PATCH 1/4] improvement(settings): consolidate resource UI onto shared primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandboxes, MCP tools, and MCP servers each modeled their list rows and detail headers differently. Collapse them — and the surfaces they drifted from — onto one set of primitives. SettingsResourceRow now owns the row entirely: tile, title/subtitle tokens, padding and bleed, hover band, hit area, focus ring, and the one navigation chevron. Adds onClick/href (a stretched overlay, so interactive trailing controls keep their clicks), navigable, iconVariant='custom', and a badge slot for decoration that must not swallow row clicks. Rows that open a detail page get a chevron and a whole-row click; flat records keep the "..." menu. Delete moves to the detail header as a destructive chip behind a confirm modal — sandboxes previously deleted with no confirmation at all. Also folds in SettingsField (new), SettingsEmptyState tone='error', RESOURCE_LIST_STACK/GRID, RESOURCE_TILE_PLAIN, and a shared MemberAvatar; deletes DetailIconTile (byte-identical to ResourceTile); and standardizes on the emcn ArrowRight, which is a visibly different glyph from lucide's. --- .claude/rules/sim-settings-pages.md | 113 +++++++-- .../components/credential-detail-heading.tsx | 16 +- .../components/credential-detail-layout.tsx | 4 +- .../components/detail-icon-tile.tsx | 14 -- .../components/detail-section.tsx | 15 +- .../components/credential-detail/index.ts | 1 - .../components/resource-tile/index.ts | 1 + .../resource-tile/resource-tile.tsx | 3 + .../[block]/integration-block-detail.tsx | 72 +++--- .../[block]/integration-skills-section.tsx | 46 ++-- .../integration-section.tsx | 20 +- .../integrations-showcase.tsx | 16 +- .../showcase-with-explore.tsx | 2 +- .../connected-credential-detail.tsx | 12 +- .../integrations/integrations.tsx | 48 ++-- .../settings/components/admin/admin.tsx | 4 +- .../settings/components/api-keys/api-keys.tsx | 125 ++++------ .../settings/components/billing/billing.tsx | 3 +- .../password-detail/password-detail.test.tsx | 2 + .../password-detail/password-detail.tsx | 25 +- .../passwords-view/passwords-view.test.tsx | 13 +- .../passwords-view/passwords-view.tsx | 55 ++--- .../components/byok/byok-key-manager.tsx | 36 ++- .../components/byok/byok-skeleton.tsx | 6 +- .../settings/components/copilot/copilot.tsx | 43 ++-- .../custom-tool-detail/custom-tool-detail.tsx | 23 +- .../components/custom-tools/custom-tools.tsx | 37 ++- .../settings/components/desktop/desktop.tsx | 41 ++-- .../inbox-task-list/inbox-task-list.tsx | 10 +- .../settings/components/mcp/mcp.tsx | 188 +++++++-------- .../settings/components/member-list/index.ts | 3 +- .../components/member-list/member-list.tsx | 34 +-- .../recently-deleted/recently-deleted.tsx | 15 +- .../sandboxes/components/sandbox-editor.tsx | 11 +- .../components/sandboxes/sandboxes.tsx | 98 ++++---- .../settings/components/sandboxes/utils.ts | 9 + .../secrets-manager/secrets-manager.tsx | 13 +- .../settings-empty-state.tsx | 11 +- .../components/settings-field/index.ts | 1 + .../settings-field/settings-field.tsx | 23 ++ .../components/settings-resource-row/index.ts | 7 +- .../settings-resource-row.tsx | 156 +++++++++--- .../settings-section/settings-section.tsx | 2 +- .../create-workflow-mcp-server-modal.tsx | 4 +- .../workflow-mcp-servers.tsx | 224 ++++++++++-------- .../secrets/[credentialId]/secret-detail.tsx | 9 +- .../skills/[skillId]/skill-detail.tsx | 5 +- .../workspace/[workspaceId]/skills/skills.tsx | 88 +++---- .../components/plan-card/plan-card.tsx | 8 +- .../components/permissions/member-avatar.tsx | 29 +++ .../sim/components/permissions/member-row.tsx | 20 +- .../components/access-control.tsx | 52 ++-- .../components/group-detail.tsx | 13 +- .../components/custom-blocks.tsx | 38 ++- .../components/data-drains-settings.tsx | 61 +++-- .../components/data-retention-settings.tsx | 52 ++-- apps/sim/ee/sso/components/sso-auth.tsx | 2 +- apps/sim/ee/sso/components/sso-form.tsx | 2 +- .../components/fork-sync/fork-sync-view.tsx | 2 +- 59 files changed, 1036 insertions(+), 950 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-icon-tile.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx create mode 100644 apps/sim/components/permissions/member-avatar.tsx diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 8710553e800..734770a17c5 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -107,19 +107,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 +140,98 @@ 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 hover band, the hit area, the focus ring, and the +navigation chevron. 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..66b256b567a 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 { @@ -200,22 +204,36 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {workspaceKeys.length === 0 ? (
No workspace API keys yet
) : ( -
+
{workspaceKeys.map((key) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
+ { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + canDelete={canManageWorkspaceKeys} + /> + } + /> + ))} +
+ )} + + ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( + +
+ {filteredWorkspaceKeys.map(({ key }) => ( + { @@ -224,38 +242,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 +251,29 @@ 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) - }} - /> -
+
+ { + 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..b16bf4ac2df 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,11 +142,10 @@ 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 @@ -153,15 +158,13 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta
-
+ -
- 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..3020f7d239c 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' @@ -23,7 +23,10 @@ import { 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,26 +311,21 @@ 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) => ( ))} @@ -346,13 +344,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 index 71d220b9c37..edd8c1e6f01 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx @@ -5,9 +5,9 @@ import { Skeleton } from '@sim/emcn' */ 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..93ac321922d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -18,6 +18,10 @@ import { Plus } from 'lucide-react' 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,23 @@ 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 - -
+ { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + > + Delete + + } + /> ))} {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..e1119124114 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 ? [] @@ -228,16 +229,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..681e353b6c9 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,16 @@ 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 { + SETTINGS_FIELD_VALUE_CLASSES, + 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 +75,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 +87,10 @@ function ServerListItem({ canManage, server, tools, - isDeleting, isConnecting, isLoadingTools = false, isRefreshing = false, discoveryError = null, - onRemove, onViewDetails, onAuthorize, }: ServerListItemProps) { @@ -110,56 +114,41 @@ 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 statusLabel = isConnecting + ? 'Waiting for authorization...' + : isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : showDiscoveryError + ? discoveryError + : `${transportLabel} · ${toolsLabel}` + return ( -
-
-
- - {server.name || 'Unnamed server'} - - ({transportLabel}) -
-

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

-
-
- {canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( + {statusLabel} + + } + onClick={onViewDetails} + clickLabel={`Open ${serverName}`} + navigable + trailing={ + canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' ? ( {isConnecting ? 'Reopen authorization' : 'Authorize'} - )} - -
-
+ ) : undefined + } + /> ) } @@ -247,6 +236,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 +394,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 +443,34 @@ export function MCP() { text: 'Edit', onSelect: () => setEditingServerId(server.id), }, + { + 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 +479,11 @@ export function MCP() { server.authType )}

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

+

Parameters

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

+

{paramDesc}

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

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

-
+ + {getErrorMessage(listError, 'Failed to load MCP servers')} + ) : serversLoading ? ( Loading... ) : !hasServers ? ( @@ -667,7 +681,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 +696,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 +705,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 +739,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/member-list/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts index 21f94ee9db7..6d5f1e56c8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts @@ -1 +1,2 @@ -export { MemberAvatar, MemberRow, MemberSection } from './member-list' +export { MemberAvatar } from '@/components/permissions/member-avatar' +export { MemberRow, MemberSection } from './member-list' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx index 8966401f6b5..cdb12acbe5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx @@ -1,41 +1,15 @@ 'use client' import type { ReactNode } from 'react' +import { MemberAvatar } from '@/components/permissions/member-avatar' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -const ROW_CLASSES = 'flex items-center gap-2.5 p-2' +const ROW_CLASSES = '-mx-2 flex items-center gap-2.5 rounded-lg p-2' const ROW_EMAIL_CLASSES = 'min-w-0 flex-1 truncate text-[var(--text-body)] text-sm' const ROW_STATUS_CLASSES = 'flex-shrink-0 text-[var(--text-muted)] text-caption' -interface MemberAvatarProps { - name: string - image: string | null -} - -/** - * 14px circular avatar used in member rows. Falls back to the first letter of - * the member's name when no image is available. - */ -export function MemberAvatar({ name, image }: MemberAvatarProps) { - if (image) { - return ( - {name} - ) - } - - return ( - - {name.charAt(0).toUpperCase()} - - ) -} - interface MemberRowProps { name: string email: string @@ -91,7 +65,7 @@ export function MemberSection({ {isEmpty ? ( {emptyText} ) : ( -
{children}
+
{children}
)} ) 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..c2af440ea95 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) 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..313a5474b12 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, @@ -25,6 +24,7 @@ import { draftFromSandbox, emptyDraft, extractIssues, + SANDBOX_DELETE_CONFIRM_TEXT, SANDBOX_UPGRADE_DESCRIPTION, SANDBOX_UPGRADE_TITLE, type SandboxDraft, @@ -32,8 +32,10 @@ import { } 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 @@ -140,6 +143,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 +211,9 @@ export function Sandboxes() { ...(selected && canAdmin ? [ { - text: 'Delete', - textTone: 'error' as const, - onSelect: () => void handleDelete(selected), + text: deleteSandbox.isPending ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => setShowDeleteConfirm(true), disabled: deleteSandbox.isPending, }, ] @@ -225,6 +229,22 @@ export function Sandboxes() { /> + {selected && ( + void handleDelete(selected), + pending: deleteSandbox.isPending, + pendingLabel: 'Deleting...', + }} + /> + )} + - - {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) => ( + } + 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..ab39d7f284f 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,15 @@ 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. */ +export const SANDBOX_DELETE_CONFIRM_TEXT = [ + { + text: 'This will permanently delete the sandbox and remove it from any Function blocks that are using it.', + error: true, + }, + ' This action cannot be undone.', +] as const + /** 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..e2207ab7625 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts @@ -0,0 +1 @@ +export { SETTINGS_FIELD_VALUE_CLASSES, 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..a6eaa5ec8f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +interface SettingsFieldProps { + label: ReactNode + 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. + */ +export function SettingsField({ label, children }: SettingsFieldProps) { + return ( +
+ {label} + {children} +
+ ) +} + +/** Value text inside a {@link SettingsField}. */ +export const SETTINGS_FIELD_VALUE_CLASSES = 'text-[var(--text-body)] text-sm' 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..f494e552307 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,5 +1,7 @@ import type { ReactNode } 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, @@ -16,15 +18,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 +48,57 @@ 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 && ( @@ -93,26 +144,53 @@ export function SettingsResourceRow({
) - 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 = '-mx-2 flex items-center justify-between gap-2.5 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}
+
) } 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..801b243e3e8 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,16 @@ 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 { + SETTINGS_FIELD_VALUE_CLASSES, + 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 +74,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) @@ -364,7 +384,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe return (
-

+

Failed to load server details

@@ -393,6 +413,16 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe ? 'All deployed workflows have been added to this server.' : undefined, }, + ...(onDelete + ? [ + { + text: isDeleting ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: onDelete, + disabled: isDeleting, + }, + ] + : []), ] : [] } @@ -410,7 +440,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
{activeServerTab === 'workflows' && (
- Workflows + Workflows {tools.length === 0 ? (

@@ -449,7 +479,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )} {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

+

Deploy a workflow first to add it to this server.

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

{server.name}

-
-
- - Transport - -

Streamable-HTTP

-
-
- Access -

+ +

{server.name}

+ + +

Streamable-HTTP

+
+ +

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

-
+
{server.description?.trim() && ( -
- - Description - -

{server.description}

-
+ +

{server.description}

+
)} -
- URL -

{mcpServerUrl}

-
+ +

{mcpServerUrl}

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

+

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

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

+

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

- + @@ -835,7 +854,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe 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'} @@ -926,6 +945,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 +976,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 +1037,41 @@ 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), - }, - ] - : []), - ]} - /> -
-
+ } + 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 +1093,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..9c8268c2e3e 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 { AgentSkillsIcon } from '@/components/icons' 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) => ( + } + iconFilled + 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/components/permissions/member-avatar.tsx b/apps/sim/components/permissions/member-avatar.tsx new file mode 100644 index 00000000000..f13e86f6c21 --- /dev/null +++ b/apps/sim/components/permissions/member-avatar.tsx @@ -0,0 +1,29 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn' +import { getUserColor } from '@/lib/workspaces/colors' + +interface MemberAvatarProps { + name: string + image?: string | null + /** Seed for the generated fallback background; defaults to the name. */ + colorSeed?: string +} + +/** + * The one avatar worn by every member row — Teammates, Organization, and the + * credential Members sections. Matches `RESOURCE_TILE_BASE`'s 36px footprint so + * a member row keeps the same rhythm as a resource row. + */ +export function MemberAvatar({ name, image, colorSeed }: MemberAvatarProps) { + const initial = (name || '?').charAt(0).toUpperCase() + return ( + + {image ? : null} + + {initial} + + + ) +} diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx index 8a235584204..db9b237453a 100644 --- a/apps/sim/components/permissions/member-row.tsx +++ b/apps/sim/components/permissions/member-row.tsx @@ -1,7 +1,7 @@ 'use client' -import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn' -import { getUserColor } from '@/lib/workspaces/colors' +import { Chip, ChipDropdown, cn } from '@sim/emcn' +import { MemberAvatar } from '@/components/permissions/member-avatar' import type { MemberRole } from './member-role-options' import { RoleLockTooltip } from './role-lock' @@ -49,20 +49,16 @@ export function MemberRow({ return (
- - - {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} - - -
+ +
{member.userName || member.userEmail || member.userId} 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..14696a7cf23 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -48,6 +48,7 @@ import { 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 { RESOURCE_LIST_STACK } 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 { getAllBlocks } from '@/blocks' @@ -451,7 +452,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)]' > @@ -1619,7 +1620,7 @@ export function GroupDetail({ />
{viewingGroup.workspaces.length > 0 && ( -
+
{viewingGroup.workspaces.map((ws) => (
{membersLoading ? ( -
+
{[1, 2].map((i) => ( -
- +
+
))}
) : ( members.length > 0 && ( -
+
{members.map((member) => ( ) : ( -
+
{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/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, From f381712ea22b09119a3644c4c65c98f77683820b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 2 Aug 2026 21:14:07 -0700 Subject: [PATCH 2/4] fix(settings): correct row bleed, delete-confirm binding, and avatar seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from review of the consolidation. The sandbox delete confirmation was boolean-only state. Browser Back unmounts the modal without closing it, so opening another sandbox re-opened it already confirmed — against the wrong sandbox. Reset it where the draft guard already handles the same history hazard. RESOURCE_LIST_GRID kept an 8px column gap after the bleed moved onto the row, so neighbouring cards overlapped by exactly the gutter and the right-hand card's stretched hit area won it: clicking between two cards opened the wrong one. Budget the gap for the bleed instead. Also: restore the `group` the template-icon hover outlines depend on; add a `flush` row for headings and overflow boxes; seed MemberAvatar identically on every surface; keep the MCP transport label visible in all row states; give the Delete chip a stable id so it doesn't remount mid-delete; and stop an empty subtitle rendering a phantom line. Docs: widen the rule's path globs to the surfaces it now governs, drop the `text-[14px]` example that contradicted the token rule, and add a Mode C for migrating rows onto the primitive. --- .claude/rules/sim-settings-pages.md | 24 ++++++++++----- .claude/rules/sim-styling.md | 8 +++-- .claude/skills/add-settings-page/SKILL.md | 30 ++++++++++++++++++- .../custom-tool-detail/custom-tool-detail.tsx | 1 + .../settings/components/mcp/mcp.tsx | 6 ++-- .../components/member-list/member-list.tsx | 2 +- .../components/sandboxes/sandboxes.tsx | 5 ++++ .../settings-resource-row.tsx | 26 ++++++++++++---- .../workflow-mcp-servers.tsx | 1 + .../workspace/[workspaceId]/skills/skills.tsx | 6 ++-- .../components/group-detail.tsx | 12 ++++---- 11 files changed, 95 insertions(+), 26 deletions(-) diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 734770a17c5..12c6159d2cb 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/**" --- @@ -173,8 +176,10 @@ navigation chevron. Never hand-roll any of it, and never wrap the row in your ow `iconFill` lets an uploaded image reach the tile edge. - `onClick` / `href` — makes the **whole row** activatable via a stretched overlay. Prefer `href` when the destination is a route, so the row keeps - prefetch, middle-click, and open-in-new-tab. `clickLabel` is the accessible - name and is **required** alongside either: the overlay has no text of its own. + prefetch, middle-click, and open-in-new-tab. Always pass `clickLabel` with + either — the overlay holds no text, so it is the control's *only* accessible + name. The prop is optional in the type (nothing enforces it), so omitting it + ships a nameless button rather than failing the build. - `navigable` — appends the one canonical chevron. Set it on rows that open a detail page; leave it off when `onClick` acts in place (revealing a folder). Never import an arrow yourself: `lucide-react` and `@sim/emcn/icons` ship @@ -187,9 +192,13 @@ navigation chevron. Never hand-roll any of it, and never wrap the row in your ow containers. A `SettingsResourceRow` carries its own `-mx-2` bleed and padding, so a container holding one only sets rhythm: never add a second `-mx-2` (they stack into a 16px bleed) and never a different gap. A list of hand-rolled rows - is the opposite — there the container owns the bleed. Note `-mx-2` inside a - fixed-height `overflow-y-auto` box forces a horizontal scrollbar; drop the - bleed there. + is the opposite — there the container owns the bleed. A row inside a + fixed-height `overflow-y-auto` box, or a heading that must line up with the + section labels under it, passes `flush` to drop the bleed and padding. + `RESOURCE_LIST_GRID` budgets its column gap for the bleed (24px of track gap + minus 16px of bleed = an 8px gutter); narrowing that gap makes neighbouring + rows — and their stretched hit areas — overlap, so a click in the gutter opens + the wrong card. **Three-dots vs. chevron** is not a taste call: @@ -221,7 +230,8 @@ navigation chevron. Never hand-roll any of it, and never wrap the row in your ow Conditional items become array spreads: `...(canManage ? [{…}] : [])`. Never hand-roll the `` + `` trigger per page. - **`RESOURCE_TILE_BASE`** + one of `RESOURCE_TILE_FILL` / `RESOURCE_TILE_PLAIN` - (`…/components/resource-tile`) — the 36px tile chrome, for the rare tile + (`app/workspace/[workspaceId]/components/resource-tile` — note: *not* under + `settings/`, unlike the other `…/` paths on this page) — the 36px tile chrome, for the rare tile outside a row (a detail heading). `ResourceTile` wraps the filled pairing. - **`MemberAvatar`** (`@/components/permissions/member-avatar`) — the one avatar for any member row. @@ -316,7 +326,7 @@ A settings page is design-system-clean when: - [ ] Its `NavigationItem` has an accurate, consistent-length `description`. - [ ] Detail sub-views and entitlement/loading gates keep their own chrome (intentional). - [ ] If it has editable state: Save/Discard go through `SaveDiscardActions`, dirty is wired via `useSettingsUnsavedGuard` (called before any early-return gate), and there is **no** hand-rolled Save button / `beforeunload` / "Unsaved changes" modal. -- [ ] No business logic, handlers, or conditional rendering changed by the migration. +- [ ] No business logic, handlers, or conditional rendering changed by the migration — except where the shared primitive makes a gate structural (a permission gate becomes `onClick={can ? … : undefined}` + `navigable={can}`, which renders a plain non-interactive row). - [ ] No literal `text-[Npx]` classes — named scale tokens only (see "Text-scale tokens" above). - [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper `