` + hand-rolled `
`/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
- **`ChipDatePicker`** — chip-styled date field.
+- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
## Authoring principles
-- **One source of truth for shared chrome.** Compose from `chip-field-chrome.ts` / `chipVariants`; never duplicate the chrome string.
+- **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string.
- **`cn()` for a single state toggle, CVA for genuine multiple variants.** A lone `error` boolean is `cn()`, not a CVA variant.
- **Discriminated-union props for modes** (e.g. `multiple`, the modal field `type`) instead of near-duplicate components.
- **Delete legacy variants after migration** — don't leave dead paths (this paradigm removed `Input variant='chip'` and `ChipMultiSelect`).
diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md
index 9b4d9cb3842..61e4c5d1967 100644
--- a/.claude/rules/sim-styling.md
+++ b/.claude/rules/sim-styling.md
@@ -52,7 +52,7 @@ Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text
## Chip Components (consumer usage)
-`ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `apps/sim/components/emcn/components/chip-input/chip-field-chrome.ts` — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings.
+`ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `apps/sim/components/emcn/components/chip/chip-chrome.ts` (all tokens are re-exported from the `@/components/emcn` barrel — no subpath import needed) — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings.
### Props over className
@@ -71,6 +71,8 @@ Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins
- **Field row** = `ChipModalField`: label↔control `gap-[9px]`, field gutter `px-2` (`px-0` when `flush`). Title = `Label` at `text-small` (13px), muted, normal weight; hint/error at `text-caption` (12px).
- **Modal body** (`ChipModalBody`): `gap-4` between fields, padding `px-2 pt-4 pb-4.5`.
- **Header/footer**: horizontal gutter `px-4` (header `pt-3`; footer `px-4 pt-2 pb-2`, tinted bar).
+- **Every body field MUST be a `ChipModalField`** — NEVER hand-roll a field row (raw `` + hand-rolled `
`/`` title + bare `ChipInput`/`ChipTextarea`). WHY: body `px-2` + field `px-2` = effective `px-4`, exactly matching the `px-4` header/footer. A hand-rolled row skips the field gutter, sits at `px-2`, and is visibly misaligned (this bug shipped in the scheduled-tasks "Create new scheduled task" modal). Inline errors go through the `error` prop, not a hand-rolled ``.
+- **Uncovered controls** (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX) → `ChipModalField type='custom'` with a `title`. It still applies the `px-2` gutter and renders the canonical `Label`, so it stays aligned. Never drop such a control into a raw `
`, and never add a body-level wrapper `
` with a custom `gap-*` that fights `gap-4`.
- **Page section rhythm** (integrations/skills/settings): muted `text-small` label + `mt-[9px] mb-3 h-px bg-[var(--border)]` divider, sections stacked `gap-7`. Reuse `SettingsSection` (`app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx`) rather than re-deriving it.
When a standalone labeled field outside a `ChipModal` needs the same look (e.g. `SkillImport`), match the field rhythm by hand: `flex flex-col gap-[9px]`, muted label, `ChipInput`/`ChipTextarea` control, `text-caption` error below.
diff --git a/.cursor/commands/emcn-design-review.md b/.cursor/commands/emcn-design-review.md
index e51e5bc4445..750f4c4ef2a 100644
--- a/.cursor/commands/emcn-design-review.md
+++ b/.cursor/commands/emcn-design-review.md
@@ -12,7 +12,7 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit
## Steps
-1. Read the emcn barrel export at `apps/sim/components/emcn/components/index.ts` to know what's available
+1. Read the emcn public barrel at `apps/sim/components/emcn/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `apps/sim/components/emcn/icons/index.ts`
2. Read `apps/sim/app/_styles/globals.css` for CSS variable tokens
3. Analyze the specified scope against every rule below
4. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
@@ -29,18 +29,22 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit
Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantics (`text-muted-foreground`) or hardcoded colors (`text-gray-500`, `#333`).
-**Text**: `--text-primary`, `--text-secondary`, `--text-tertiary`, `--text-muted`, `--text-icon`, `--text-inverse`, `--text-error`
-**Surfaces**: `--bg`, `--surface-2` through `--surface-7`, `--surface-hover`, `--surface-active`
+**Text**: `--text-primary`, `--text-secondary`, `--text-tertiary`, `--text-muted`, `--text-body` (canonical value text), `--text-icon`, `--text-placeholder`, `--text-subtle`, `--text-inverse`, `--text-error`
+**Surfaces**: `--bg`, `--surface-1` through `--surface-7`, `--surface-hover`, `--surface-active`
**Borders**: `--border`, `--border-1`, `--border-muted`
+**Brand/accent**: `--brand-secondary`, `--brand-accent`
**Z-Index**: `--z-dropdown` (100), `--z-modal` (200), `--z-popover` (300), `--z-tooltip` (400), `--z-toast` (500)
**Shadows**: `shadow-subtle`, `shadow-medium`, `shadow-overlay`, `shadow-card`
+**Badges**: `--badge-*` semantic families (success/error/gray/blue/purple/orange/amber/teal/cyan/pink, each with `-bg`/`-text`)
## Buttons
+Intent-to-variant mapping (read the actual `buttonVariants` in `apps/sim/components/emcn/components/button/button.tsx` for the full variant set — it exposes more than listed here):
+
| Action | Variant |
|--------|---------|
-| Toolbar, icon-only | `ghost` (most common, 28%) |
-| Create, save, submit | `primary` (24%) |
+| Toolbar, icon-only | `ghost` |
+| Create, save, submit | `primary` |
| Cancel, close | `default` |
| Delete, remove | `destructive` |
| Selected state | `active` |
@@ -48,7 +52,7 @@ Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantic
## Delete/Remove Confirmations
-Modal `size="sm"`, title "Delete/Remove {ItemType}", `variant="destructive"` action button, `variant="default"` cancel. Cancel left, action right (100% compliance). Use `text-[var(--text-error)]` for irreversible warnings.
+`ChipModal` `size='sm'`, title "Delete/Remove {ItemType}", destructive confirm button, plain Cancel (follow the chip footer layout in `.claude/rules/emcn-components.md`). Use `text-[var(--text-error)]` for irreversible warnings.
## Toast
@@ -64,7 +68,8 @@ Default: `size-[14px]`. Color: `text-[var(--text-icon)]`. Scale: 14px > 16px > 1
## Anti-patterns to flag
-- Raw `
`/` ` instead of emcn components
+- Raw ``/` `, or legacy `Input`/`Textarea`/`Modal`, instead of the canonical chip components (`ChipInput`/`ChipTextarea`/`ChipModal`)
+- Hand-rolled field rows inside a `ChipModalBody` instead of `ChipModalField`
- Hardcoded colors (`text-gray-*`, `#hex`, `rgb()`)
- Tailwind semantics (`text-muted-foreground`) instead of CSS variables
- Template literal className instead of `cn()`
diff --git a/.cursor/rules/emcn-components.mdc b/.cursor/rules/emcn-components.mdc
index be34ad31cbb..cfa03764482 100644
--- a/.cursor/rules/emcn-components.mdc
+++ b/.cursor/rules/emcn-components.mdc
@@ -4,13 +4,13 @@ globs: ["apps/sim/components/emcn/**"]
---
# EMCN Components
-Import from `@/components/emcn`, never from subpaths (except CSS files). The **chip family** is the platform's primary chrome — prefer it over the legacy primitives (`Input`, `Textarea`, etc.), which it is progressively replacing.
+Import from `@/components/emcn`, never from subpaths (except CSS files). The **chip family** is the platform's primary chrome — always reach for it over the legacy primitives it is progressively replacing (`Input`→`ChipInput`, `Textarea`→`ChipTextarea`, `Modal`→`ChipModal`, `Select`/`Combobox`→`ChipSelect`/`ChipCombobox`/`ChipDropdown`, `Switch`→`ChipSwitch`, date field→`ChipDatePicker`). For context/action menus the canonical control is `DropdownMenu` — the standard menu (not a chip, and never a hand-rolled popover).
## Chip chrome — single source of truth
Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources:
-- **Surface + typography tokens:** `chip-input/chip-field-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass`. Text fields and the dropdown search box build on these.
+- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, and `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells). All are re-exported from the `@/components/emcn` barrel — no subpath import needed.
- **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity.
Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border-1` border.
@@ -24,14 +24,15 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default).
- **`ChipDropdown`** — pill that opens a menu. Single OR multi-select via the discriminated `multiple` prop (one component, not two). Owns its trailing chevron — no `rightIcon`.
- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`.
-- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch.
+- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `` + hand-rolled `
`/`` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
- **`ChipDatePicker`** — chip-styled date field.
+- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
## Authoring principles
-- **One source of truth for shared chrome.** Compose from `chip-field-chrome.ts` / `chipVariants`; never duplicate the chrome string.
+- **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string.
- **`cn()` for a single state toggle, CVA for genuine multiple variants.** A lone `error` boolean is `cn()`, not a CVA variant.
- **Discriminated-union props for modes** (e.g. `multiple`, the modal field `type`) instead of near-duplicate components.
- **Delete legacy variants after migration** — don't leave dead paths (this paradigm removed `Input variant='chip'` and `ChipMultiSelect`).
diff --git a/.cursor/rules/sim-styling.mdc b/.cursor/rules/sim-styling.mdc
index 8d4c20b87d7..cdf392cd7f4 100644
--- a/.cursor/rules/sim-styling.mdc
+++ b/.cursor/rules/sim-styling.mdc
@@ -50,7 +50,7 @@ Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text
## Chip Components (consumer usage)
-`ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `apps/sim/components/emcn/components/chip-input/chip-field-chrome.ts` — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings.
+`ChipInput`, `ChipTextarea`, `ChipModal*` own their full chrome. Consumers describe intent through PROPS; they never re-style the chrome. The canonical chrome lives in `apps/sim/components/emcn/components/chip/chip-chrome.ts` (all tokens are re-exported from the `@/components/emcn` barrel — no subpath import needed) — never hand-roll `rounded-lg`/`border`/`bg-[var(--surface-5)]`/`h-[30px]`/`px-2`/`text-sm`/focus rings.
### Props over className
@@ -69,6 +69,8 @@ Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins
- **Field row** = `ChipModalField`: label↔control `gap-[9px]`, field gutter `px-2` (`px-0` when `flush`). Title = `Label` at `text-small` (13px), muted, normal weight; hint/error at `text-caption` (12px).
- **Modal body** (`ChipModalBody`): `gap-4` between fields, padding `px-2 pt-4 pb-4.5`.
- **Header/footer**: horizontal gutter `px-4` (header `pt-3`; footer `px-4 pt-2 pb-2`, tinted bar).
+- **Every body field MUST be a `ChipModalField`** — NEVER hand-roll a field row (raw `` + hand-rolled `
`/`` title + bare `ChipInput`/`ChipTextarea`). WHY: body `px-2` + field `px-2` = effective `px-4`, exactly matching the `px-4` header/footer. A hand-rolled row skips the field gutter, sits at `px-2`, and is visibly misaligned (this bug shipped in the scheduled-tasks "Create new scheduled task" modal). Inline errors go through the `error` prop, not a hand-rolled ``.
+- **Uncovered controls** (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX) → `ChipModalField type='custom'` with a `title`. It still applies the `px-2` gutter and renders the canonical `Label`, so it stays aligned. Never drop such a control into a raw `
`, and never add a body-level wrapper `
` with a custom `gap-*` that fights `gap-4`.
- **Page section rhythm** (integrations/skills/settings): muted `text-small` label + `mt-[9px] mb-3 h-px bg-[var(--border)]` divider, sections stacked `gap-7`. Reuse `SettingsSection` (`app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx`) rather than re-deriving it.
When a standalone labeled field outside a `ChipModal` needs the same look (e.g. `SkillImport`), match the field rhythm by hand: `flex flex-col gap-[9px]`, muted label, `ChipInput`/`ChipTextarea` control, `text-caption` error below.
diff --git a/AGENTS.md b/AGENTS.md
index 7afc9dab68e..78feaedb30a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,29 +45,26 @@ apps/
│ ├── tools/ # Tool definitions
│ └── triggers/ # Trigger definitions
└── realtime/ # Bun Socket.IO server (collaborative canvas)
- └── src/ # auth, config, database, handlers, middleware,
- # rooms, routes, internal/webhook-cleanup.ts
packages/
-├── audit/ # @sim/audit — recordAudit + AuditAction + AuditResourceType
-├── auth/ # @sim/auth — @sim/auth/verify (shared Better Auth verifier)
+├── audit/ # @sim/audit
+├── auth/ # @sim/auth — shared Better Auth verifier
├── db/ # @sim/db — drizzle schema + client
├── logger/ # @sim/logger
-├── realtime-protocol/ # @sim/realtime-protocol — socket operation constants + zod schemas
+├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas
├── security/ # @sim/security — safeCompare
├── tsconfig/ # shared tsconfig presets
├── utils/ # @sim/utils
-├── workflow-authz/ # @sim/workflow-authz — authorizeWorkflowByWorkspacePermission
-├── workflow-persistence/ # @sim/workflow-persistence — raw load/save + subflow helpers
-└── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel/... types
+├── workflow-authz/ # @sim/workflow-authz
+├── workflow-persistence/ # @sim/workflow-persistence
+└── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel types
```
### Package boundaries
- `apps/* → packages/*` only. Packages never import from `apps/*`.
-- Each package has explicit subpath `exports` maps; no barrels that accidentally pull in heavy halves.
-- `apps/realtime` intentionally avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`.
-- Auth is shared across services via the Better Auth "Shared Database Session" pattern: both apps read the same `BETTER_AUTH_SECRET` and point at the same DB via `@sim/db`.
+- `apps/realtime` intentionally avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. Do not add imports from `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` to any package consumed by `apps/realtime`. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`.
+- Auth is shared across both apps via the Better Auth "Shared Database Session" pattern (same `BETTER_AUTH_SECRET`, same DB via `@sim/db`).
### Naming Conventions
@@ -146,7 +143,7 @@ Domain validators that are not HTTP boundaries — tools, blocks, triggers, conn
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:
-- `// boundary-raw-fetch:
` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**`, `apps/sim/hooks/selectors/**`, or any other client/UI source under `apps/sim/**` that targets a same-origin `/api/...` URL outside an API route handler. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests
+- `// boundary-raw-fetch: ` — placed on the line directly above a raw `fetch(` call in client hooks (`apps/sim/hooks/queries/**`, `apps/sim/hooks/selectors/**`) AND any same-origin `/api/...` fetch elsewhere under `apps/sim/**` outside an API route handler. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests
- `// double-cast-allowed: ` — placed on the line directly above an `as unknown as X` cast outside test files
- `// boundary-raw-json: ` — placed on the line directly above a raw `await request.json()` / `await req.json()` read in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest`
- `// untyped-response: ` — placed on the line directly above a `schema: z.unknown()` response declaration in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough)
@@ -169,7 +166,7 @@ const provider = config as unknown as LegacyProvider
## API Route Pattern
-Every API route handler must be wrapped with `withRouteHandler`. This sets up `AsyncLocalStorage`-based request context so all loggers in the request lifecycle automatically include the request ID. Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`.
+Every API route handler must be wrapped with `withRouteHandler`. This sets up `AsyncLocalStorage`-based request context so all loggers in the request lifecycle automatically include the request ID.
Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`:
@@ -191,11 +188,11 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('FoldersAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
- const parsed = await parseRequest(createFolderContract, request, {})
- if (!parsed.success) return parsed.response
- const { body } = parsed.data
- logger.info('Creating folder', { workspaceId: body.workspaceId })
- return NextResponse.json({ ok: true })
+ const parsed = await parseRequest(createFolderContract, request, {})
+ if (!parsed.success) return parsed.response
+ const { body } = parsed.data
+ logger.info('Creating folder', { workspaceId: body.workspaceId })
+ return NextResponse.json({ ok: true })
})
```
@@ -209,6 +206,8 @@ export const POST = withRouteHandler(withAdminAuth(async (request) => {
Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route.
+Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`.
+
### Adding a new boundary feature end-to-end
When adding a new route + client surface, follow this order. Each step has one place it lives.
@@ -282,7 +281,7 @@ Hooks consume contracts the same way routes do. Every same-origin JSON call must
- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code
- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation
-- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies
+- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies. The `// boundary-raw-fetch` annotation is required not only in client hooks but for any same-origin `/api/...` fetch anywhere under `apps/sim/**` outside an API route handler — strict CI flags these regardless of location
```typescript
import { keepPreviousData, useQuery } from '@tanstack/react-query'
@@ -388,7 +387,9 @@ On chip components (see "EMCN Components"), drive chrome through PROPS, not `cla
Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA only when 2+ genuine variants exist; otherwise plain `cn()`.
-The chip pill is the canonical UI chrome and is progressively replacing the legacy EMCN primitives — prefer the chip equivalents (`ChipInput`/`ChipTextarea` over `Input`/`Textarea`, plus `Chip`/`ChipLink`, `ChipDropdown`, `ChipSelect`/`ChipCombobox`, `ChipModal`, `ChipSwitch`, `ChipTag`, `ChipDatePicker`). Components OWN their chrome (single source of truth) — consumers pass props, not class overrides. Authoring rules in `.claude/rules/emcn-components.md`; consumer rules in `.claude/rules/sim-styling.md`.
+The chip family is the canonical UI chrome and is progressively replacing the legacy EMCN primitives — always reach for the chip equivalent: `ChipInput` over `Input`, `ChipTextarea` over `Textarea`, `ChipModal`/`ChipModalField` over `Modal`, `ChipSelect`/`ChipCombobox` (searchable) or `ChipDropdown` (simple menu-select) over `Select`/`Combobox`, `ChipSwitch` over `Switch`, `ChipDatePicker` over a raw date field, `Chip`/`ChipLink` for pill buttons/links, `ChipTag` for inline tags/badges. For context/action menus the canonical control is `DropdownMenu` (not a chip, but the standard menu — not a hand-rolled popover). Components OWN their chrome (single source of truth) — consumers pass props, not class overrides. Authoring rules in `.claude/rules/emcn-components.md`; consumer rules in `.claude/rules/sim-styling.md`.
+
+Inside a `ChipModalBody`, EVERY labeled field MUST be a `ChipModalField` — never hand-roll a field row (a raw `` + a hand-rolled `
`/`` title + a bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds ANOTHER `px-2`, so each field lands at effective `px-4`, exactly matching `ChipModalHeader`/`ChipModalFooter` (`px-4`). Hand-rolled rows skip the field's gutter and sit at `px-2`, visibly misaligned with the header/footer. For controls `ChipModalField` does not cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `ChipModalField type='custom'` with a `title` — it still applies the `px-2` gutter and renders the canonical `Label`. Drive intent via props (`title`/`value`/`onChange`/`error`/`hint`/`required`/`flush`); never pass `variant`/`className`/`id` to the inner control, and never add a body-level wrapper `` with a custom `gap-*` that fights `ChipModalBody`'s `gap-4`.
## Design-System Consolidation
@@ -408,7 +409,7 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/s
### Global Mocks (vitest.setup.ts)
-`@sim/db`, `drizzle-orm`, `@sim/logger`, `@/blocks/registry`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior.
+`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/workflow-authz`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.)
### Standard Test Pattern
@@ -458,126 +459,12 @@ Use `@sim/testing` mocks/factories over local test data.
## Adding Integrations
-New integrations require: **Tools** → **Block** → **Icon** → (optional) **Trigger**
-
-Always look up the service's API docs first.
-
-### 1. Tools (`tools/{service}/`)
-
-```
-tools/{service}/
-├── index.ts # Barrel export
-├── types.ts # Params/response types
-└── {action}.ts # Tool implementation
-```
-
-**Tool structure:**
-
-```typescript
-export const serviceTool: ToolConfig
= {
- id: 'service_action',
- name: 'Service Action',
- description: '...',
- version: '1.0.0',
- oauth: { required: true, provider: 'service' },
- params: { /* ... */ },
- request: { url: '/api/tools/service/action', method: 'POST', ... },
- transformResponse: async (response) => { /* ... */ },
- outputs: { /* ... */ },
-}
-```
-
-Register in `tools/registry.ts`.
-
-### 2. Block (`blocks/blocks/{service}.ts`)
-
-```typescript
-export const ServiceBlock: BlockConfig = {
- type: 'service',
- name: 'Service',
- description: '...',
- category: 'tools',
- bgColor: '#hexcolor',
- icon: ServiceIcon,
- subBlocks: [ /* see SubBlock Properties */ ],
- tools: { access: ['service_action'], config: { tool: (p) => `service_${p.operation}`, params: (p) => ({ /* type coercions here */ }) } },
- inputs: { /* ... */ },
- outputs: { /* ... */ },
-}
-```
-
-Register in `blocks/registry.ts` (alphabetically).
-
-**Important:** `tools.config.tool` runs during serialization (before variable resolution). Never do `Number()` or other type coercions there — dynamic references like `` will be destroyed. Use `tools.config.params` for type coercions (it runs during execution, after variables are resolved).
-
-**SubBlock Properties:**
-
-```typescript
-{
- id: 'field', title: 'Label', type: 'short-input', placeholder: '...',
- required: true, // or condition object
- condition: { field: 'op', value: 'send' }, // show/hide
- dependsOn: ['credential'], // clear when dep changes
- mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
-}
-```
-
-**condition examples:**
-
-- `{ field: 'op', value: 'send' }` - show when op === 'send'
-- `{ field: 'op', value: ['a','b'] }` - show when op is 'a' OR 'b'
-- `{ field: 'op', value: 'x', not: true }` - show when op !== 'x'
-- `{ field: 'op', value: 'x', not: true, and: { field: 'type', value: 'dm', not: true } }` - complex
-
-**dependsOn:** `['field']` or `{ all: ['a'], any: ['b', 'c'] }`
-
-**File Input Pattern (basic/advanced mode):**
-
-```typescript
-// Basic: file-upload UI
-{ id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' },
-// Advanced: reference from other blocks
-{ id: 'fileRef', type: 'short-input', canonicalParamId: 'file', mode: 'advanced' },
-```
-
-In `tools.config.tool`, normalize with:
-
-```typescript
-import { normalizeFileInput } from '@/blocks/utils'
-const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true })
-if (file) params.file = file
-```
-
-For file uploads, create an internal API route (`/api/tools/{service}/upload`) that uses `downloadFileFromStorage` to get file content from `UserFile` objects.
-
-### 3. Icon (`components/icons.tsx`)
-
-```typescript
-export function ServiceIcon(props: SVGProps) {
- return /* SVG from brand assets */
-}
-```
-
-### 4. Trigger (`triggers/{service}/`) - Optional
-
-```
-triggers/{service}/
-├── index.ts # Barrel export
-├── webhook.ts # Webhook handler
-└── {event}.ts # Event-specific handlers
-```
+New integrations are built in order: **Tools** → **Block** → **Icon** → (optional) **Trigger**. Always look up the service's API docs first.
-Register in `triggers/registry.ts`.
+Two hard rules that the skills assume:
-### Integration Checklist
+- **Tool IDs are `snake_case`** (`service_action`) and must be registered in `tools/registry.ts`; blocks register in `blocks/registry.ts` (alphabetically).
+- **`tools.config.tool` runs during serialization (before variable resolution)** — never do `Number()` or other type coercions there, or dynamic references like `` are destroyed. Put all type coercions in `tools.config.params`, which runs during execution after variables resolve.
-- Look up API docs
-- Create `tools/{service}/` with types and tools
-- Register tools in `tools/registry.ts`
-- Add icon to `components/icons.tsx`
-- Create block in `blocks/blocks/{service}.ts`
-- Register block in `blocks/registry.ts`
-- (Optional) Create and register triggers
-- (If file uploads) Create internal API route with `downloadFileFromStorage`
-- (If file uploads) Use `normalizeFileInput` in block config
+For the full authoring instructions — SubBlock property tables, `condition`/`dependsOn`/`required`/`mode`/`canonicalParamId` syntax, required block metadata (`integrationType`, `tags`, `authMode`, `docsLink`, `{Service}BlockMeta`), file-input/`normalizeFileInput` patterns, and checklists — use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`.
diff --git a/CLAUDE.md b/CLAUDE.md
index d3012be8b22..78feaedb30a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -387,7 +387,9 @@ On chip components (see "EMCN Components"), drive chrome through PROPS, not `cla
Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA only when 2+ genuine variants exist; otherwise plain `cn()`.
-The chip pill is the canonical UI chrome and is progressively replacing the legacy EMCN primitives — prefer the chip equivalents (`ChipInput`/`ChipTextarea` over `Input`/`Textarea`, plus `Chip`/`ChipLink`, `ChipDropdown`, `ChipSelect`/`ChipCombobox`, `ChipModal`, `ChipSwitch`, `ChipTag`, `ChipDatePicker`). Components OWN their chrome (single source of truth) — consumers pass props, not class overrides. Authoring rules in `.claude/rules/emcn-components.md`; consumer rules in `.claude/rules/sim-styling.md`.
+The chip family is the canonical UI chrome and is progressively replacing the legacy EMCN primitives — always reach for the chip equivalent: `ChipInput` over `Input`, `ChipTextarea` over `Textarea`, `ChipModal`/`ChipModalField` over `Modal`, `ChipSelect`/`ChipCombobox` (searchable) or `ChipDropdown` (simple menu-select) over `Select`/`Combobox`, `ChipSwitch` over `Switch`, `ChipDatePicker` over a raw date field, `Chip`/`ChipLink` for pill buttons/links, `ChipTag` for inline tags/badges. For context/action menus the canonical control is `DropdownMenu` (not a chip, but the standard menu — not a hand-rolled popover). Components OWN their chrome (single source of truth) — consumers pass props, not class overrides. Authoring rules in `.claude/rules/emcn-components.md`; consumer rules in `.claude/rules/sim-styling.md`.
+
+Inside a `ChipModalBody`, EVERY labeled field MUST be a `ChipModalField` — never hand-roll a field row (a raw `` + a hand-rolled `
`/`` title + a bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds ANOTHER `px-2`, so each field lands at effective `px-4`, exactly matching `ChipModalHeader`/`ChipModalFooter` (`px-4`). Hand-rolled rows skip the field's gutter and sit at `px-2`, visibly misaligned with the header/footer. For controls `ChipModalField` does not cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `ChipModalField type='custom'` with a `title` — it still applies the `px-2` gutter and renders the canonical `Label`. Drive intent via props (`title`/`value`/`onChange`/`error`/`hint`/`required`/`flush`); never pass `variant`/`className`/`id` to the inner control, and never add a body-level wrapper `` with a custom `gap-*` that fights `ChipModalBody`'s `gap-4`.
## Design-System Consolidation
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx
index da31dfcd20e..4d9c5bcef30 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx
@@ -432,7 +432,7 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
return (
- {/* Breadcrumb header — matches real ResourceHeader breadcrumb layout */}
+ {/* Breadcrumb header — matches real Resource.Header breadcrumb layout */}
- {/* Breadcrumb */}
-
-
{/* Tooltip */}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
index 8be15eea1a1..7afd46637ae 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
@@ -10,10 +10,7 @@
* to the canonical chip input instead of each re-deriving the tokens.
*/
-import {
- chipFieldSurfaceClass,
- chipFieldTextClass,
-} from '@/components/emcn/components/chip-input/chip-field-chrome'
+import { chipFieldSurfaceClass, chipFieldTextClass } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */
diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts
index 19e2cbd893d..dd30b61d6d1 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts
@@ -4,27 +4,34 @@ export { ErrorShell, ErrorState } from './error'
export { InlineRenameInput } from './inline-rename-input'
export { MessageActions } from './message-actions'
export { ownerCell } from './resource/components/owner-cell'
+export {
+ type ChromeActionSpec,
+ ResourceChromeFallback,
+} from './resource/components/resource-chrome-fallback'
export type {
+ BreadcrumbEditing,
BreadcrumbItem,
- CreateAction,
- HeaderAction,
+ DropdownOption,
+ ResourceAction,
} from './resource/components/resource-header'
-export { ResourceHeader } from './resource/components/resource-header'
export type {
ColumnOption,
+ FilterConfig,
FilterTag,
SearchConfig,
+ SearchTag,
SortConfig,
-} from './resource/components/resource-options-bar'
-export { ResourceOptionsBar } from './resource/components/resource-options-bar'
+} from './resource/components/resource-options'
+export { SortDropdown } from './resource/components/resource-options'
export { timeCell } from './resource/components/time-cell'
export type {
PaginationConfig,
ResourceCell,
+ ResourceCellEditing,
ResourceColumn,
ResourceRow,
RowDragDropConfig,
SelectableConfig,
} from './resource/resource'
-export { EMPTY_CELL_PLACEHOLDER, Resource, ResourceTable } from './resource/resource'
+export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource'
export { SkillTile } from './skill-tile'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx b/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx
index f35aa7d33bf..52f9f130ef1 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx
@@ -7,17 +7,45 @@ interface InlineRenameInputProps {
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
+ /**
+ * Disables the field while the rename is in flight, mirroring the sidebar's
+ * `disabled={isRenaming}`. Threaded from `useInlineRename`'s `isSaving`.
+ */
+ disabled?: boolean
}
-export function InlineRenameInput({ value, onChange, onSubmit, onCancel }: InlineRenameInputProps) {
+/**
+ * Inline rename field used by every resource rename surface (tables, files,
+ * knowledge), triggered from a context menu. Matches the sidebar workflow rename
+ * (`useItemRename`) input verbatim: same focus-reset className and attribute set
+ * (`maxLength`, autocomplete/correct/capitalize off, spellCheck off), focus +
+ * select on mount, commit on blur, Enter to submit, Escape to cancel, disabled
+ * while saving. The only intentional delta is the table-cell `size={...}`
+ * auto-width, which the sidebar (full-width row) does not need.
+ *
+ * The triggering menu uses `onCloseAutoFocus={(e) => e.preventDefault()}`, so the
+ * Radix focus-scope teardown never steals focus back from this freshly-focused
+ * input. `onSubmit` (from `useInlineRename`) is idempotent via its `doneRef`
+ * guard, so a blur racing an Enter/Escape commit is a harmless no-op.
+ *
+ * TODO: the resource rename still intermittently unfocuses; the deeper re-render
+ * cause (parent remounting the editing cell) is tracked separately. This input is
+ * aligned to the proven sidebar pattern as the first step.
+ */
+export function InlineRenameInput({
+ value,
+ onChange,
+ onSubmit,
+ onCancel,
+ disabled = false,
+}: InlineRenameInputProps) {
const inputRef = useRef(null)
useEffect(() => {
const el = inputRef.current
- if (el) {
- el.focus()
- el.select()
- }
+ if (!el) return
+ el.focus()
+ el.select()
}, [])
return (
@@ -27,13 +55,24 @@ export function InlineRenameInput({ value, onChange, onSubmit, onCancel }: Inlin
value={value}
size={Math.max(value.length + 2, 5)}
onChange={(e) => onChange(e.target.value)}
+ onBlur={onSubmit}
onKeyDown={(e) => {
- if (e.key === 'Enter') onSubmit()
- if (e.key === 'Escape') onCancel()
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ onSubmit()
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ onCancel()
+ }
}}
- onBlur={onSubmit}
onClick={(e) => e.stopPropagation()}
- className='min-w-0 border-0 bg-transparent p-0 font-medium text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0'
+ className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
+ maxLength={100}
+ disabled={disabled}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
/>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index aa4eff641e5..c5ccfcc4bc6 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -240,7 +240,7 @@ export const MessageActions = memo(function MessageActions({
>
handleModalClose(false)}>Give feedback
-
+
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/index.ts
new file mode 100644
index 00000000000..d6ad61338aa
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/index.ts
@@ -0,0 +1,2 @@
+export type { ChromeActionSpec } from './resource-chrome-fallback'
+export { ResourceChromeFallback } from './resource-chrome-fallback'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx
new file mode 100644
index 00000000000..5328e163c47
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx
@@ -0,0 +1,92 @@
+'use client'
+
+import type { ComponentType } from 'react'
+import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
+import {
+ Resource,
+ type ResourceColumn,
+} from '@/app/workspace/[workspaceId]/components/resource/resource'
+
+/**
+ * The static visual shape of a header action chip. The loading fallback only
+ * needs the chrome (text/icon/variant/active) — handlers are no-ops during a
+ * route transition — so the dynamic `onSelect`/`disabled` are intentionally
+ * omitted. Mirrors {@link ResourceAction}'s chrome fields.
+ */
+export interface ChromeActionSpec {
+ text: string
+ icon?: ComponentType<{ className?: string }>
+ variant?: 'primary' | 'destructive'
+ active?: boolean
+}
+
+interface ResourceChromeFallbackProps {
+ /** Title-mode icon (list pages) or breadcrumb-root icon (detail pages). */
+ icon?: ComponentType<{ className?: string }>
+ /** Title-mode label. Omit when `breadcrumbs` is supplied. */
+ title?: string
+ /**
+ * Static breadcrumb trail for detail-route fallbacks. The live leaf name is
+ * unknown at load, so pass the known root crumb(s) plus a terminal `…`
+ * placeholder; it fills in once the page mounts.
+ */
+ breadcrumbs?: BreadcrumbItem[]
+ /** Table column headers. Omit on bodies that don't render `Resource.Table` (the table editor). */
+ columns?: ResourceColumn[]
+ /** The page's exact header action chips (handlers wired to a no-op). */
+ actions?: ChromeActionSpec[]
+ /** Search placeholder. Omit to hide the search box (matches a page with no search). */
+ searchPlaceholder?: string
+ /** Paint the Sort chip (its menu never opens during the fallback, so the option list is irrelevant). */
+ hasSort?: boolean
+ /** Paint the Filter chip. */
+ hasFilter?: boolean
+}
+
+const noop = () => {}
+
+/**
+ * Route-transition fallback rendered by each resource route's `loading.tsx`. It
+ * paints the REAL resource chrome — the header (icon/title or breadcrumbs + the
+ * page's exact action chips), the options bar (search + the Filter/Sort chips),
+ * and the table's column headers — with an empty body and no-op handlers, so a
+ * navigation never shows a blank frame or a skeleton. Only the breadcrumb leaf
+ * and the row data are unknown at load; everything else matches the loaded page.
+ */
+export function ResourceChromeFallback({
+ icon,
+ title,
+ breadcrumbs,
+ columns,
+ actions,
+ searchPlaceholder,
+ hasSort = false,
+ hasFilter = false,
+}: ResourceChromeFallbackProps) {
+ return (
+
+ ({
+ text: action.text,
+ icon: action.icon,
+ variant: action.variant,
+ active: action.active,
+ onSelect: noop,
+ }))}
+ />
+
+ {columns ? : null}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts
index 697f8c9aacc..eaa02307577 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts
@@ -1,8 +1,7 @@
export type {
BreadcrumbEditing,
BreadcrumbItem,
- CreateAction,
DropdownOption,
- HeaderAction,
+ ResourceAction,
} from './resource-header'
export { ResourceHeader } from './resource-header'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
index 5fa3411e222..375e1c1dada 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
@@ -1,16 +1,25 @@
-import { Fragment, forwardRef, memo, useEffect, useRef, useState } from 'react'
+import {
+ type ComponentType,
+ Fragment,
+ forwardRef,
+ memo,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
import { ArrowUpLeft } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
- Button,
- ChevronDown,
+ Chip,
+ ChipChevronDown,
+ chipGeometryClass,
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FloatingTooltip,
- Plus,
POPOVER_ANIMATION_CLASSES,
Popover,
PopoverAnchor,
@@ -24,8 +33,6 @@ import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
-const HEADER_PLUS_ICON =
-
export interface DropdownOption {
label: string
icon?: React.ElementType
@@ -39,6 +46,13 @@ export interface BreadcrumbEditing {
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
+ /**
+ * Disables the rename field while the save is in flight, mirroring
+ * {@link ResourceCellEditing.disabled} on table cells. Threaded from
+ * `useInlineRename`'s `isSaving`. Optional so existing consumers keep
+ * working unchanged.
+ */
+ disabled?: boolean
}
export interface BreadcrumbItem {
@@ -54,17 +68,19 @@ export interface BreadcrumbItem {
terminal?: boolean
}
-export interface HeaderAction {
- label: string
- icon?: React.ElementType
- onClick: () => void
- disabled?: boolean
+/**
+ * The single, strict contract for a top-right header action. Every action renders
+ * as a {@link Chip} — consumers describe intent through these fields and nothing
+ * else, so the action row looks identical on every page and cannot drift. Omit
+ * `variant` for the default chip; use `primary`/`destructive` for emphasis. Express
+ * a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
+ */
+export interface ResourceAction {
+ icon?: ComponentType<{ className?: string }>
+ text: string
+ variant?: 'primary' | 'destructive'
active?: boolean
-}
-
-export interface CreateAction {
- label: string
- onClick: () => void
+ onSelect: () => void
disabled?: boolean
}
@@ -72,32 +88,36 @@ interface ResourceHeaderProps {
icon?: React.ElementType
title?: string
breadcrumbs?: BreadcrumbItem[]
- create?: CreateAction
- actions?: HeaderAction[]
- /** Arbitrary content rendered in the right-aligned actions row, before `actions`. */
- leadingActions?: React.ReactNode
- /** Arbitrary content rendered in the right-aligned actions row, before the Create button. */
- trailingActions?: React.ReactNode
+ /** Strict top-right action chips. List pages use ONLY this. */
+ actions?: ResourceAction[]
/**
- * Replaces the default Create button entirely — supply your own trigger (for
- * example a dropdown) when the create action needs richer UI. When provided,
- * `create` is ignored.
+ * Supplementary right-aligned content rendered before `actions` — custom
+ * widgets that cannot collapse into the strict {@link ResourceAction} chip
+ * contract, e.g. the table editor's run/stop control, an import-progress
+ * menu, or a create dropdown. Anything that fits the chip contract belongs
+ * in `actions`; never stuff primary actions in here.
*/
- createTrigger?: React.ReactNode
+ aside?: ReactNode
}
export const ResourceHeader = memo(function ResourceHeader({
icon: Icon,
title,
breadcrumbs,
- create,
actions,
- leadingActions,
- trailingActions,
- createTrigger,
+ aside,
}: ResourceHeaderProps) {
const headerRef = useRef
(null)
- const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0
+ /**
+ * Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb
+ * "breadcrumb" is just the current page, so it falls through to the static
+ * title below — keeping the top-left non-interactive and hover-free,
+ * identical to a title-only page (e.g. the Files root matches the Tables root).
+ */
+ const hasBreadcrumbs = breadcrumbs != null && breadcrumbs.length > 1
+ const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined
+ const TitleIcon = Icon ?? rootCrumb?.icon
+ const titleLabel = title ?? rootCrumb?.label
const terminalBreadcrumbIndex =
hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1
const currentResourceIndex =
@@ -108,13 +128,7 @@ export const ResourceHeader = memo(function ResourceHeader({
: -1
return (
-
+
{hasBreadcrumbs ? (
@@ -126,6 +140,12 @@ export const ResourceHeader = memo(function ResourceHeader({
terminalBreadcrumbIndex
)
const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
+ /**
+ * The first crumb on a nested page opens the hover "path" popover
+ * (back-navigation). Single-crumb roots never reach here — they
+ * render as the static title above.
+ */
+ const showLocationPopover = LocationIcon != null
return (
@@ -134,7 +154,7 @@ export const ResourceHeader = memo(function ResourceHeader({
/
)}
- {LocationIcon ? (
+ {showLocationPopover ? (
) : (
- {Icon && }
- {title && (
-
-
-
+
+ >
+ {TitleIcon && }
+ {titleLabel && (
+
+ )}
+
)}
-
- {leadingActions}
- {actions?.map((action) => {
- const ActionIcon = action.icon
- return (
-
0)) && (
+
+ {aside}
+ {actions?.map((action) => (
+
- {ActionIcon && (
-
- )}
- {action.label}
-
- )
- })}
- {trailingActions}
- {createTrigger ??
- (create && (
-
- {HEADER_PLUS_ICON}
- {create.label}
-
+ {action.text}
+
))}
-
+
+ )}
)
@@ -264,13 +266,14 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
if (editing?.isEditing) {
return (
-
- {Icon && }
+
+ {Icon && }
)
@@ -282,9 +285,15 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
>
)
+ /**
+ * Interactive crumbs use a plain `` with bare-chip geometry — NEVER
+ * the Button component, whose buttonVariants inject font-medium /
+ * rounded-[5px] / justify-center and break chip parity with the static/title
+ * crumbs.
+ */
const triggerClassName = cn(
- chipVariants({ variant: 'ghost', flush: true }),
- 'group min-w-0 max-w-full justify-start font-medium transition-colors'
+ chipVariants({ flush: true }),
+ 'group min-w-0 max-w-full justify-start'
)
if (dropdownItems && dropdownItems.length > 0) {
@@ -293,14 +302,10 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
-
+
{content}
-
-
+
+
{dropdownItems.map((item) => {
@@ -322,14 +327,14 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
return (
<>
-
{content}
-
+
>
)
}
@@ -339,8 +344,8 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
+
)
const RESOURCE_MENU_EDGE_OFFSET = 6
@@ -66,33 +66,39 @@ export interface SearchConfig {
dropdownRef?: React.RefObject
}
-interface ResourceOptionsBarProps {
+/**
+ * The Filter control has two shapes, picked by `mode`:
+ * - `popover` (default): list pages pass the filter controls as `content`; the
+ * button opens them in a popover. `active` highlights the button.
+ * - `toggle`: detail views (e.g. the table editor) that render their filter as a
+ * separate panel toggle the button instead of opening a popover.
+ */
+export type FilterConfig =
+ | { mode?: 'popover'; content: ReactNode; active?: boolean }
+ | { mode: 'toggle'; active: boolean; onToggle: () => void }
+
+interface ResourceOptionsProps {
search?: SearchConfig
sort?: SortConfig
- /** Popover content — renders inside a Popover (used by logs, etc.) */
- filter?: ReactNode
- /** When provided, Filter button acts as a toggle instead of opening a Popover */
- onFilterToggle?: () => void
- /** Whether the filter is currently active (highlights the toggle button) */
- filterActive?: boolean
+ filter?: FilterConfig
filterTags?: FilterTag[]
- extras?: ReactNode
- /** Right-aligned slot. Unlike `extras` (which sits with the left controls),
- * `trailing` is pushed to the far right via `justify-between` — used for the
- * table's run/stop control opposite the left-aligned filter/sort. */
- trailing?: ReactNode
+ /**
+ * Supplementary right-aligned slot (pushed opposite the left-aligned
+ * filter/sort via `justify-between`) for lightweight status content — e.g.
+ * the knowledge list's connector badges or the table editor's run/stop
+ * control in embedded mode. Keep it to badges/status widgets; primary
+ * actions belong in the header's `actions`, not here.
+ */
+ aside?: ReactNode
}
-export const ResourceOptionsBar = memo(function ResourceOptionsBar({
+export const ResourceOptions = memo(function ResourceOptions({
search,
sort,
filter,
- onFilterToggle,
- filterActive,
filterTags,
- extras,
- trailing,
-}: ResourceOptionsBarProps) {
+ aside,
+}: ResourceOptionsProps) {
/**
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
* one while the other is open switches to it in a single click. Functional
@@ -101,51 +107,27 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
*/
const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null)
- const hasContent =
- search ||
- sort ||
- filter ||
- onFilterToggle ||
- extras ||
- trailing ||
- (filterTags && filterTags.length > 0)
+ const isToggleFilter = filter?.mode === 'toggle'
+ const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null
+
+ const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
if (!hasContent) return null
return (
{search &&
}
-
- {extras}
+
{filterTags?.map((tag) => (
-
-
- ✕
-
+
+ {tag.label}
+
))}
- {onFilterToggle ? (
-
-
+ {isToggleFilter && filter.mode === 'toggle' ? (
+
Filter
-
- ) : filter ? (
+
+ ) : popoverFilter ? (
@@ -153,12 +135,11 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
}
>
-
+
-
-
+
Filter
-
+
{sort && (
- {filter}
+ {popoverFilter.content}
) : null}
- {sort && (onFilterToggle || !filter) && }
+ {sort && (isToggleFilter || !popoverFilter) && }
- {trailing &&
{trailing}
}
+ {aside &&
{aside}
}
)
@@ -199,24 +180,21 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
return (
-
+
{SEARCH_ICON}
-
+
{search.tags?.map((tag, i) => (
-
{tag.label}: {tag.value}
- ✕
-
+
))}
{search.tags?.length || search.value ? (
search.onChange(''))}
>
✕
@@ -269,21 +247,9 @@ export const SortDropdown = memo(function SortDropdown({
return (
-
-
+
Sort
-
+
+import type { SortConfig } from './components/resource-options'
+import { ResourceOptions } from './components/resource-options'
export interface ResourceColumn {
id: string
@@ -26,10 +33,30 @@ export interface ResourceColumn {
widthMultiplier?: number
}
+export interface ResourceCellEditing {
+ value: string
+ onChange: (value: string) => void
+ onSubmit: () => void
+ onCancel: () => void
+ /**
+ * Disables the rename field while the save is in flight, mirroring the
+ * sidebar's `disabled={isRenaming}`. Threaded from `useInlineRename`'s
+ * `isSaving`. Optional so existing consumers keep working unchanged.
+ */
+ disabled?: boolean
+}
+
export interface ResourceCell {
icon?: ReactNode
label?: string | null
content?: ReactNode
+ /**
+ * When set, the cell renders an inline rename field inside the canonical cell
+ * chrome (icon + {@link InlineRenameInput}). Consumers pass structured handlers
+ * instead of hand-rolling a `content` node, so every rename cell matches the
+ * resting cell exactly (same gap, weight, icon size).
+ */
+ editing?: ResourceCellEditing
}
export interface ResourceRow {
@@ -65,110 +92,36 @@ export interface PaginationConfig {
onPageChange: (page: number) => void
}
+export const EMPTY_CELL_PLACEHOLDER = '—'
+
interface ResourceProps {
- icon: React.ElementType
- title: string
- breadcrumbs?: BreadcrumbItem[]
- create?: CreateAction
- search?: SearchConfig
- defaultSort?: string
- sort?: SortConfig
- headerActions?: HeaderAction[]
- leadingActions?: ReactNode
- columns: ResourceColumn[]
- rows: ResourceRow[]
- selectedRowId?: string | null
- selectable?: SelectableConfig
- rowDragDrop?: RowDragDropConfig
- onRowClick?: (rowId: string) => void
- onRowHover?: (rowId: string) => void
- onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
- isLoading?: boolean
+ children: ReactNode
onContextMenu?: (e: React.MouseEvent) => void
- filter?: ReactNode
- filterTags?: FilterTag[]
- extras?: ReactNode
- pagination?: PaginationConfig
- emptyMessage?: string
- overlay?: ReactNode
}
-export const EMPTY_CELL_PLACEHOLDER = '—'
-const SKELETON_ROW_COUNT = 5
-
/**
- * Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
- * Renders the header, toolbar with search, and a data table from column/row definitions.
+ * Compound page shell for resource pages (tables, files, knowledge, schedules,
+ * logs, and the detail editors). Consumers import only `Resource` and fill the
+ * defined slots as children:
+ *
+ * - `Resource.Header` — required, the top bar (title/breadcrumbs + action chips)
+ * - `Resource.Options` — required, the search/filter/sort toolbar
+ * - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …)
+ *
+ * The shell owns the fixed column layout; the children own their own chrome.
*/
-export const Resource = memo(function Resource({
- icon,
- title,
- breadcrumbs,
- create,
- search,
- defaultSort,
- sort: sortOverride,
- headerActions,
- leadingActions,
- columns,
- rows,
- selectedRowId,
- selectable,
- rowDragDrop,
- onRowClick,
- onRowHover,
- onRowContextMenu,
- isLoading,
- onContextMenu,
- filter,
- filterTags,
- extras,
- pagination,
- emptyMessage,
- overlay,
-}: ResourceProps) {
+function ResourceRoot({ children, onContextMenu }: ResourceProps) {
return (
-
-
-
+ {children}
)
-})
+}
-export interface ResourceTableProps {
+interface ResourceTableProps {
columns: ResourceColumn[]
rows: ResourceRow[]
defaultSort?: string
@@ -180,7 +133,6 @@ export interface ResourceTableProps {
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isLoading?: boolean
- create?: CreateAction
onLoadMore?: () => void
hasMore?: boolean
isLoadingMore?: boolean
@@ -190,10 +142,10 @@ export interface ResourceTableProps {
}
/**
- * Data table body extracted from Resource for independent composition.
- * Use directly when rendering a table without the Resource header/toolbar.
+ * Data table body, module-private and exposed only as `Resource.Table` — the
+ * compound member is the sole way consumers render it.
*/
-export const ResourceTable = memo(function ResourceTable({
+const ResourceTable = memo(function ResourceTable({
columns,
rows,
defaultSort,
@@ -205,7 +157,6 @@ export const ResourceTable = memo(function ResourceTable({
onRowHover,
onRowContextMenu,
isLoading,
- create,
onLoadMore,
hasMore,
isLoadingMore,
@@ -283,7 +234,6 @@ export const ResourceTable = memo(function ResourceTable({
}, [onLoadMore, hasMore])
const hasCheckbox = selectable != null
- const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
const handleSelectAll = useCallback(
(checked: boolean | 'indeterminate') => {
@@ -292,17 +242,12 @@ export const ResourceTable = memo(function ResourceTable({
[selectable]
)
- if (isLoading) {
- return (
-
- )
- }
-
- if (rows.length === 0 && emptyMessage) {
+ /**
+ * While loading, the table chrome (column headers) renders with an empty body
+ * and the rows "just load in" — never a skeleton, and never a false
+ * empty-state (the empty message is gated on `!isLoading`).
+ */
+ if (!isLoading && rows.length === 0 && emptyMessage) {
return (
{emptyMessage}
@@ -333,7 +278,7 @@ export const ResourceTable = memo(function ResourceTable({
return (
{col.header}
@@ -343,9 +288,9 @@ export const ResourceTable = memo(function ResourceTable({
const SortIcon = internalSort.direction === 'asc' ? ArrowUp : ArrowDown
return (
-
handleSort(
col.id,
@@ -354,10 +299,8 @@ export const ResourceTable = memo(function ResourceTable({
}
>
{col.header}
- {isActive && (
-
- )}
-
+ {isActive && }
+
)
})}
@@ -379,7 +322,6 @@ export const ResourceTable = memo(function ResourceTable({
hasCheckbox={hasCheckbox}
/>
))}
- {create &&
}
{hasMore && (
@@ -463,23 +405,33 @@ const Pagination = memo(function Pagination({
})
interface CellContentProps {
+ /** Pre-rendered icon node (svg/img/span avatar); auto-sized to the chip icon size. */
icon?: ReactNode
label: string
content?: ReactNode
- primary?: boolean
+ editing?: ResourceCellEditing
}
-const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) {
+const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
+ if (editing) {
+ return (
+
+ {icon && {icon} }
+
+
+ )
+ }
if (content) return <>{content}>
return (
-
- {icon && {icon} }
-
+
+ {icon && {icon} }
+
)
})
@@ -616,7 +568,7 @@ const DataRow = memo(function DataRow({
/>
)}
- {columns.map((col, colIdx) => {
+ {columns.map((col) => {
const cell = row.cells[col.id]
return (
@@ -624,7 +576,7 @@ const DataRow = memo(function DataRow({
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
- primary={colIdx === 0}
+ editing={cell?.editing}
/>
)
@@ -633,30 +585,6 @@ const DataRow = memo(function DataRow({
)
})
-interface CreateRowProps {
- create: CreateAction
- totalColSpan: number
-}
-
-const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) {
- return (
-
-
-
- {CREATE_ROW_PLUS_ICON}
- {create.label}
-
-
-
- )
-})
-
interface ResourceColGroupProps {
columns: ResourceColumn[]
hasCheckbox?: boolean
@@ -682,60 +610,13 @@ const ResourceColGroup = memo(function ResourceColGroup({
)
})
-interface DataTableSkeletonProps {
- columns: ResourceColumn[]
- rowCount: number
- hasCheckbox?: boolean
-}
-
-const DataTableSkeleton = memo(function DataTableSkeleton({
- columns,
- rowCount,
- hasCheckbox,
-}: DataTableSkeletonProps) {
- return (
-
-
-
-
-
- {hasCheckbox && (
-
-
-
- )}
- {columns.map((col) => (
-
-
-
-
-
- ))}
-
-
-
- {Array.from({ length: rowCount }, (_, i) => (
-
- {hasCheckbox && (
-
-
-
- )}
- {columns.map((col, colIdx) => (
-
-
- {colIdx === 0 && }
-
-
-
- ))}
-
- ))}
-
-
-
- )
+/**
+ * The single public entry point. `Resource` is the layout shell; its compound
+ * members are the only building blocks consumers compose. Import `Resource` and
+ * nothing else from this module.
+ */
+export const Resource = Object.assign(ResourceRoot, {
+ Header: ResourceHeader,
+ Options: ResourceOptions,
+ Table: ResourceTable,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
index 84281727c6d..ce52af4fbf4 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
-import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared'
+import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
import { PreviewToolbar } from './preview-toolbar'
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
import { useDocPreviewBinary } from './use-doc-preview-binary'
@@ -219,7 +219,7 @@ export const DocxPreview = memo(function DocxPreview({
const error = resolvePreviewError(preview.error, renderError)
if (error) return
- const showSkeleton = !hasRenderedPreview && (!fileData || rendering)
+ const showLoadingFrame = !hasRenderedPreview && (!fileData || rendering)
const scrollToPage = (page: number) => {
const scrollContainer = scrollContainerRef.current
@@ -288,10 +288,11 @@ export const DocxPreview = memo(function DocxPreview({
ref={scrollContainerRef}
className='relative min-h-0 flex-1 overflow-auto bg-[var(--surface-1)]'
>
- {showSkeleton && (
-
{PDF_PAGE_SKELETON}
- )}
-
+ {showLoadingFrame && PREVIEW_LOADING_OVERLAY}
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index e8e77532305..fa770dada17 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -3,7 +3,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import dynamic from 'next/dynamic'
-import { Skeleton } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
@@ -18,7 +17,12 @@ import { ImagePreview } from './image-preview'
import type { PdfDocumentSource } from './pdf-viewer'
import { PptxPreview } from './pptx-preview'
import { resolvePreviewType } from './preview-panel'
-import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared'
+import {
+ PREVIEW_LOADING_OVERLAY,
+ PreviewError,
+ PreviewLoadingFrame,
+ resolvePreviewError,
+} from './preview-shared'
import { TextEditor } from './text-editor'
import { XlsxPreview } from './xlsx-preview'
@@ -137,7 +141,7 @@ const IframePreview = memo(function IframePreview({
if (error) return
if (!bufferSource) {
- return
{PDF_PAGE_SKELETON}
+ return
{PREVIEW_LOADING_OVERLAY}
}
return (
@@ -197,13 +201,7 @@ const AudioPreview = memo(function AudioPreview({
if (error) return
if (isLoading && !blobUrl) {
- return (
-
-
-
-
-
- )
+ return
}
return (
@@ -244,11 +242,7 @@ const VideoPreview = memo(function VideoPreview({
if (error) return
if (isLoading && !blobUrl) {
- return (
-
-
-
- )
+ return
}
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
index 53de47f1057..f45f19fafb5 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
@@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
import 'react-pdf/dist/Page/TextLayer.css'
-import { Skeleton } from '@/components/emcn'
+import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
@@ -31,28 +31,6 @@ interface PdfViewerCoreProps {
filename: string
}
-const PDF_SKELETON = (
-
- {[0, 1].map((i) => (
-
- ))}
-
-)
-
function PdfError({ error }: { error: string }) {
return (
@@ -216,7 +194,7 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P
ref={containerRef}
className='relative flex flex-1 items-start overflow-auto bg-[var(--surface-1)]'
>
- {!isDocumentReady && PDF_SKELETON}
+ {!isDocumentReady && PREVIEW_LOADING_OVERLAY}
{
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx
index ae539a66101..51702331a5f 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx
@@ -2,41 +2,18 @@
import { memo, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { Skeleton } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PptxSandboxHost } from '@/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host'
import {
+ PREVIEW_LOADING_OVERLAY,
PreviewError,
+ PreviewLoadingFrame,
resolvePreviewError,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { useDocPreviewBinary } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary'
const logger = createLogger('PptxPreview')
-const PPTX_SLIDE_SKELETON = (
-
- {[0, 1].map((i) => (
-
- ))}
-
-)
-
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {
return `${fileId}:${dataUpdatedAt}:${byteLength}`
}
@@ -78,7 +55,7 @@ export const PptxPreview = memo(function PptxPreview({
if (error) return
if (!fileData) {
- return PPTX_SLIDE_SKELETON
+ return
}
return (
@@ -90,7 +67,7 @@ export const PptxPreview = memo(function PptxPreview({
onRenderComplete={handleRenderComplete}
onRenderError={handleRenderError}
/>
- {!hasRendered && {PPTX_SLIDE_SKELETON}
}
+ {!hasRendered && PREVIEW_LOADING_OVERLAY}
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
index cecaa5f1c81..23ee5f6b010 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
@@ -21,7 +21,7 @@ import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
-import { Checkbox, highlight, languages, Skeleton } from '@/components/emcn'
+import { Checkbox, highlight, languages } from '@/components/emcn'
import '@/components/emcn/components/code/code.css'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
@@ -37,6 +37,7 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
import { useSmoothText } from '@/hooks/use-smooth-text'
import { DataTable } from './data-table'
+import { PreviewLoadingFrame } from './preview-shared'
import { ZoomablePreview } from './zoomable-preview'
interface HastNode {
@@ -328,19 +329,15 @@ function MermaidSourcePreview({
)
}
-function MermaidCodeBlockSkeleton() {
+function MermaidCodeBlockFallback() {
return (
mermaid
Rendering…
-
)
@@ -469,13 +466,9 @@ const MermaidDiagram = memo(function MermaidDiagram({
if (!trimmedDefinition || !svg || renderedDefinition !== trimmedDefinition) {
if (zoomable) {
- return (
-
-
-
- )
+ return
}
- return
+ return
}
return null
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
index 1c2281ebe62..c04898fe39c 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Skeleton } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
export function PreviewError({ label, error }: { label: string; error: string }) {
return (
@@ -28,24 +28,31 @@ export function resolvePreviewError(
return renderError
}
-export const PDF_PAGE_SKELETON = (
-
- {[0, 1].map((i) => (
-
- ))}
-
+/**
+ * Canonical blank loading overlay for previews that render into a
+ * `--surface-1` canvas. Absolutely covers the canvas (with `z-10` so it
+ * paints above in-flow render targets) until the preview is ready.
+ */
+export const PREVIEW_LOADING_OVERLAY = (
+
)
+
+interface PreviewLoadingFrameProps {
+ /** Layout/sizing-only classes for the in-flow frame (e.g. `h-full`, `flex-1`). */
+ className?: string
+ /** Background token matching the loaded sibling's canvas. Defaults to `--bg`. */
+ tone?: 'bg' | 'surface'
+}
+
+/**
+ * Canonical in-flow blank loading frame shown while a preview is fetching or
+ * rendering. The `tone` must match the background of the loaded state it is
+ * standing in for, so mount completion does not flash a different token.
+ */
+export function PreviewLoadingFrame({ className, tone = 'bg' }: PreviewLoadingFrameProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
index 7e5ab98ab0f..8cdb1cce6f1 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
@@ -4,7 +4,6 @@ import { memo, useCallback, useEffect, useReducer, useRef, useState } from 'reac
import type { OnMount } from '@monaco-editor/react'
import type { editor as MonacoEditorTypes } from 'monaco-editor'
import dynamic from 'next/dynamic'
-import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
@@ -16,6 +15,7 @@ import { useAutosave } from '@/hooks/use-autosave'
import { EditorContextMenu } from './editor-context-menu'
import type { PreviewMode } from './file-viewer'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
+import { PreviewLoadingFrame } from './preview-shared'
import {
INITIAL_TEXT_EDITOR_CONTENT_STATE,
type StreamingMode,
@@ -373,20 +373,6 @@ function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked:
})
}
-const DOCUMENT_SKELETON = (
-
-
-
-
-
-
-
-
-
-
-
-)
-
interface TextEditorProps {
file: WorkspaceFileRecord
workspaceId: string
@@ -672,7 +658,7 @@ export const TextEditor = memo(function TextEditor({
const showPreviewPane = effectiveMode !== 'editor'
if (streamingContent === undefined) {
- if (isLoading) return DOCUMENT_SKELETON
+ if (isLoading) return
if (error && !isInitialized) {
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
index c2faa5da541..acc20afbc9c 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
@@ -4,11 +4,11 @@ import { memo, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { WorkBook } from 'xlsx'
-import { Button, Skeleton } from '@/components/emcn'
+import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { DataTable } from './data-table'
-import { PreviewError, resolvePreviewError } from './preview-shared'
+import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
import { useDocPreviewBinary } from './use-doc-preview-binary'
const logger = createLogger('XlsxPreview')
@@ -22,31 +22,6 @@ interface XlsxSheet {
truncated: boolean
}
-const XLSX_SKELETON = (
-
-
-
-
-
-
-
-
- {[1, 1, 1, 1].map((_, i) => (
-
- ))}
-
- {[...Array(7)].map((_, i) => (
-
- {[1, 1, 1, 1].map((_, j) => (
-
- ))}
-
- ))}
-
-
-
-)
-
export const XlsxPreview = memo(function XlsxPreview({
file,
workspaceId,
@@ -134,7 +109,9 @@ export const XlsxPreview = memo(function XlsxPreview({
const error = resolvePreviewError(preview.error, renderError)
if (error) return
- if (!fileData || currentSheet === null) return XLSX_SKELETON
+ if (!fileData || currentSheet === null) {
+ return
+ }
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 7f025310208..2358d61182f 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -17,6 +17,7 @@ import {
FolderPlus,
Loader,
Pencil,
+ Plus,
Trash,
toast,
Upload,
@@ -45,7 +46,7 @@ import {
import type {
BreadcrumbItem,
FilterTag,
- HeaderAction,
+ ResourceAction,
ResourceColumn,
ResourceRow,
RowDragDropConfig,
@@ -54,10 +55,8 @@ import type {
} from '@/app/workspace/[workspaceId]/components'
import {
EMPTY_CELL_PLACEHOLDER,
- InlineRenameInput,
ownerCell,
Resource,
- ResourceHeader,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar'
@@ -275,23 +274,19 @@ export function Files() {
onSave: (rowId, name) => {
const parsed = parseRowId(rowId)
if (parsed.kind === 'folder') {
- updateFolder.mutate({ workspaceId, folderId: parsed.id, updates: { name } })
- return
+ return updateFolder.mutateAsync({ workspaceId, folderId: parsed.id, updates: { name } })
}
- renameFile.mutate({ workspaceId, fileId: parsed.id, name })
+ return renameFile.mutateAsync({ workspaceId, fileId: parsed.id, name })
},
})
const headerRename = useInlineRename({
- onSave: (fileId, name) => {
- renameFile.mutate({ workspaceId, fileId, name })
- },
+ onSave: (fileId, name) => renameFile.mutateAsync({ workspaceId, fileId, name }),
})
const breadcrumbRename = useInlineRename({
- onSave: (folderId, name) => {
- updateFolder.mutate({ workspaceId, folderId, updates: { name } })
- },
+ onSave: (folderId, name) =>
+ updateFolder.mutateAsync({ workspaceId, folderId, updates: { name } }),
})
const selectedFile = useMemo(
@@ -491,33 +486,24 @@ export function Files() {
if (!listRename.editingId) return baseRows
return baseRows.map((row) => {
if (row.id !== listRename.editingId) return row
- const parsed = parseRowId(row.id)
- const file = parsed.kind === 'file' ? filteredFiles.find((f) => f.id === parsed.id) : null
- const Icon = file ? getDocumentIcon(file.type || '', file.name) : Folder
return {
...row,
cells: {
...row.cells,
name: {
...row.cells.name,
- content: (
-
-
-
-
-
-
- ),
+ editing: {
+ value: listRename.editValue,
+ onChange: listRename.setEditValue,
+ onSubmit: listRename.submitRename,
+ onCancel: listRename.cancelRename,
+ disabled: listRename.isSaving,
+ },
},
},
}
})
- }, [baseRows, listRename.editingId, listRename.editValue, filteredFiles])
+ }, [baseRows, listRename.editingId, listRename.editValue, listRename.isSaving])
const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows])
@@ -1412,7 +1398,7 @@ export function Files() {
setPreviewMode((prev) => (prev === 'preview' ? 'editor' : 'preview'))
}, [])
- const fileActions = useMemo
(() => {
+ const fileActions = useMemo(() => {
if (!selectedFile) return []
const canEditText = isTextEditable(selectedFile)
const canPreview = isPreviewable(selectedFile)
@@ -1436,8 +1422,8 @@ export function Files() {
...(canEditText
? [
{
- label: saveLabel,
- onClick: handleSave,
+ text: saveLabel,
+ onSelect: handleSave,
disabled:
(!isDirty && saveStatus === 'idle') ||
saveStatus === 'saving' ||
@@ -1448,31 +1434,31 @@ export function Files() {
...(hasSplitView
? [
{
- label: nextModeLabel,
+ text: nextModeLabel,
icon: nextModeIcon,
- onClick: handleCyclePreviewMode,
+ onSelect: handleCyclePreviewMode,
},
]
: canPreview
? [
{
- label: previewMode === 'preview' ? 'Edit' : 'Preview',
+ text: previewMode === 'preview' ? 'Edit' : 'Preview',
icon: previewMode === 'preview' ? Pencil : Eye,
- onClick: handleTogglePreview,
+ onSelect: handleTogglePreview,
},
]
: []),
{
- label: 'Download',
+ text: 'Download',
icon: Download,
- onClick: handleDownloadSelected,
+ onSelect: handleDownloadSelected,
},
...(canEdit
? [
{
- label: 'Delete',
+ text: 'Delete',
icon: Trash,
- onClick: handleDeleteSelected,
+ onSelect: handleDeleteSelected,
},
]
: []),
@@ -1525,12 +1511,6 @@ export function Files() {
placeholder: 'Search files...',
}
- const createConfig = {
- label: 'New file',
- onClick: handleCreateFile,
- disabled: uploading || creatingFile || !canEdit,
- }
-
const uploadButtonLabel =
uploading && uploadProgress.total > 0
? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100
@@ -1540,22 +1520,38 @@ export function Files() {
? 'Uploading...'
: 'Upload'
- const headerActionsConfig = useMemo(
+ const headerActionsConfig = useMemo(
() => [
{
- label: uploadButtonLabel,
+ text: uploadButtonLabel,
icon: Upload,
- onClick: handleUploadClick,
+ onSelect: handleUploadClick,
disabled: uploading || !canEdit,
},
{
- label: 'New folder',
+ text: 'New folder',
icon: FolderPlus,
- onClick: handleCreateFolder,
+ onSelect: handleCreateFolder,
disabled: createFolder.isPending || !canEdit,
},
+ {
+ text: 'New file',
+ icon: Plus,
+ onSelect: handleCreateFile,
+ disabled: uploading || creatingFile || !canEdit,
+ variant: 'primary',
+ },
],
- [uploadButtonLabel, handleUploadClick, handleCreateFolder, createFolder.isPending, canEdit]
+ [
+ uploadButtonLabel,
+ handleUploadClick,
+ handleCreateFolder,
+ handleCreateFile,
+ createFolder.isPending,
+ canEdit,
+ uploading,
+ creatingFile,
+ ]
)
const handleNavigateToFiles = useCallback(() => {
@@ -1840,7 +1836,7 @@ export function Files() {
if (fileIdFromRoute && !selectedFile && isLoading) {
return (
-
+
@@ -1852,7 +1848,7 @@ export function Files() {
return (
<>
-
-
-
- {isDraggingOver ? (
-
-
-
-
- Drop to upload
-
-
- Release files here to add them to this workspace
-
+
+
+
+
+
+ {isDraggingOver ? (
+
+
+
+
+ Drop to upload
+
+
+ Release files here to add them to this workspace
+
+
-
- ) : null}
- >
- }
- />
+ ) : null}
+ >
+ }
+ />
+
-
-
-
-
-
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, i) => (
-
-
-
- ))}
-
-
-
- {Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx
index 242b7e92913..514662f7a78 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx
@@ -1,15 +1,22 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Files } from './files'
+import FilesLoading from './loading'
export const metadata: Metadata = {
title: 'Files',
robots: { index: false },
}
+/**
+ * Files page entry. `Files` reads `useSearchParams`, so it must sit under a
+ * Suspense boundary. The fallback renders the real chrome (header + options +
+ * table headers) so a suspend never shows a blank frame; the route-level
+ * `loading.tsx` covers the navigation/chunk-load transition the same way.
+ */
export default function FilesPage() {
return (
-
+ }>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
index f1fda6c482a..e71c4ed5fae 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
@@ -382,7 +382,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
aria-hidden={!expanded}
tabIndex={expanded ? undefined : -1}
className={cn(
- chipVariants({ variant: 'ghost', flush: true }),
+ chipVariants({ flush: true }),
'-mr-2 gap-1.5 transition-opacity duration-150 ease-out motion-reduce:transition-none',
expanded ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
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 91cb688b9c7..8b40a489734 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
@@ -32,7 +32,7 @@ function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) {
{skill.description}
{added ? (
-
+
Added
) : (
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 ed632532daf..73666025dec 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
@@ -30,7 +30,6 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
{
storeCuratedPrompt(prompt)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
index c309f5c9ad5..e4564019fa6 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
@@ -9,6 +9,7 @@ import {
ChipInput,
ChipModal,
ChipModalBody,
+ ChipModalField,
ChipModalFooter,
ChipModalHeader,
DatePicker,
@@ -389,10 +390,8 @@ export function DocumentTagsModal({
-
+
-
Tags
-
{documentTags.map((tag, index) => (
+
@@ -476,6 +472,7 @@ export function Document({
onChange: docRename.setEditValue,
onSubmit: docRename.submitRename,
onCancel: docRename.cancelRename,
+ disabled: docRename.isSaving,
}
: undefined,
dropdownItems: [
@@ -501,6 +498,7 @@ export function Document({
docRename.setEditValue,
docRename.submitRename,
docRename.cancelRename,
+ docRename.isSaving,
userPermissions.canEdit,
handleStartDocRename,
handleShowTags,
@@ -1008,36 +1006,36 @@ export function Document({
[handleNavigateChunk]
)
- const createActions = useMemo(
+ const createActions = useMemo(
() => [
{
- label: saveLabel,
- onClick: handleSaveClick,
+ text: saveLabel,
+ onSelect: handleSaveClick,
disabled: !isDirty || saveStatus === 'saving',
},
],
[saveLabel, handleSaveClick, isDirty, saveStatus]
)
- const editorActions = useMemo(() => {
- const actions: HeaderAction[] = [
+ const editorActions = useMemo(() => {
+ const actions: ResourceAction[] = [
{
- label: 'Previous chunk',
+ text: 'Previous chunk',
icon: ChevronUp,
- onClick: handleNavigatePrev,
+ onSelect: handleNavigatePrev,
disabled: !canNavigatePrev,
},
{
- label: 'Next chunk',
+ text: 'Next chunk',
icon: ChevronDown,
- onClick: handleNavigateNextChunk,
+ onSelect: handleNavigateNextChunk,
disabled: !canNavigateNext,
},
]
if (canEdit && !isConnectorDocument) {
actions.push({
- label: saveLabel,
- onClick: handleSaveClick,
+ text: saveLabel,
+ onSelect: handleSaveClick,
disabled: !isDirty || saveStatus === 'saving',
})
}
@@ -1059,7 +1057,7 @@ export function Document({
return (
<>
-
-
+
Loading chunk…
@@ -1102,7 +1100,7 @@ export function Document({
return (
<>
-
-
+
+
+
+
+
{}
+
+const COLUMNS = [
+ { id: 'content', header: 'Content' },
+ { id: 'index', header: 'Index', widthMultiplier: 0.6 },
+ { id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
+ { id: 'status', header: 'Status', widthMultiplier: 0.75 },
+]
+
+const ACTIONS: ChromeActionSpec[] = [{ text: 'New chunk', icon: Plus, variant: 'primary' }]
+
+const BREADCRUMBS: BreadcrumbItem[] = [
+ { label: 'Knowledge Base', icon: Database, onClick: noop },
+ { label: '…' },
+ { label: '…', terminal: true },
+]
export default function DocumentLoading() {
return (
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, i) => (
-
-
-
- ))}
-
-
-
- {Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index e1122f786d1..f0bbc811cec 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -19,6 +19,9 @@ import {
ChipModal,
ChipModalBody,
ChipModalHeader,
+ cellIconNodeClass,
+ chipContentGap,
+ chipContentLabelClass,
chipVariants,
Loader,
Tooltip,
@@ -37,7 +40,7 @@ import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import type {
BreadcrumbItem,
FilterTag,
- HeaderAction,
+ ResourceAction,
ResourceCell,
ResourceColumn,
ResourceRow,
@@ -225,7 +228,7 @@ export function KnowledgeBase({
const { mutate: deleteDocumentMutation } = useDeleteDocument()
const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } =
useDeleteKnowledgeBase(workspaceId)
- const { mutate: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
+ const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const kbRename = useInlineRename({
onSave: (kbId, name) =>
@@ -809,6 +812,7 @@ export function KnowledgeBase({
onChange: kbRename.setEditValue,
onSubmit: kbRename.submitRename,
onCancel: kbRename.cancelRename,
+ disabled: kbRename.isSaving,
}
: undefined,
dropdownItems: [
@@ -838,14 +842,14 @@ export function KnowledgeBase({
},
]
- const headerActions: HeaderAction[] = [
+ const headerActions: ResourceAction[] = [
...(userPermissions.canEdit || userPermissions.isLoading
? [
{
- label: 'New connector',
+ text: 'New connector',
icon: Plus,
disabled: !userPermissions.canEdit,
- onClick: () => setShowAddConnectorModal(true),
+ onSelect: () => setShowAddConnectorModal(true),
},
]
: []),
@@ -1065,11 +1069,14 @@ export function KnowledgeBase({
cells: {
name: {
content: (
-
-
+
+
-
+
@@ -1133,61 +1140,71 @@ export function KnowledgeBase({
return (
<>
- setCurrentPage(page),
- }}
- emptyMessage={emptyMessage}
- overlay={
- 1 ? 'bottom-[72px]' : undefined}
- selectedCount={selectedDocuments.size}
- onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
- onDisable={enabledCount > 0 ? handleBulkDisable : undefined}
- onDelete={handleBulkDelete}
- enabledCount={enabledCount}
- disabledCount={disabledCount}
- isLoading={isBulkOperating}
- totalCount={pagination.total}
- isAllPageSelected={isAllSelected}
- isAllSelected={isSelectAllMode}
- onSelectAll={() => setIsSelectAllMode(true)}
- onClearSelectAll={() => {
- setIsSelectAllMode(false)
- setSelectedDocuments(new Set())
- }}
- />
- }
- />
+
+
+
+ setCurrentPage(page),
+ }}
+ emptyMessage={emptyMessage}
+ overlay={
+ 1 ? 'bottom-[72px]' : undefined}
+ selectedCount={selectedDocuments.size}
+ onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
+ onDisable={enabledCount > 0 ? handleBulkDisable : undefined}
+ onDelete={handleBulkDelete}
+ enabledCount={enabledCount}
+ disabledCount={disabledCount}
+ isLoading={isBulkOperating}
+ totalCount={pagination.total}
+ isAllPageSelected={isAllSelected}
+ isAllSelected={isSelectAllMode}
+ onSelectAll={() => setIsSelectAllMode(true)}
+ onClearSelectAll={() => {
+ setIsSelectAllMode(false)
+ setSelectedDocuments(new Set())
+ }}
+ />
+ }
+ />
+
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
index 703d9c23e38..d0d6d21d783 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
-import { ArrowLeft, ArrowLeftRight, Info, Plus } from 'lucide-react'
+import { ArrowLeft, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
ArrowRight,
@@ -13,12 +13,12 @@ import {
ChipInput,
ChipModal,
ChipModalBody,
+ ChipModalError,
+ ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
- Label,
Search,
- Tooltip,
} from '@/components/emcn'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { cn } from '@/lib/core/utils/cn'
@@ -30,19 +30,17 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
-import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
+import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
-import type { ConfigFieldValue } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { getBlock } from '@/blocks'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
-import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
+import type { ConnectorConfig } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useSubscriptionData } from '@/hooks/queries/subscription'
-import type { SelectorKey } from '@/hooks/selectors/types'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
@@ -249,12 +247,10 @@ export function AddConnectorModal({
{step === 'select-type' ? (
-
+
setSearchTerm(e.target.value)}
/>
-
+
{filteredEntries.map(([type, config]) => (
) : connectorConfig ? (
-
+ <>
{isApiKeyMode ? (
-
-
- {connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.label
+
+ : 'API Key'
+ }
+ >
-
+
) : (
-
- Account
+
-
+
)}
- {connectorConfig.configFields.map((field) => {
- if (!isFieldVisible(field)) return null
-
- const canonicalId = field.canonicalParamId
- const hasCanonicalPair =
- canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
-
- return (
-
-
-
-
- {field.title}
- {field.required && * }
-
- {field.description && (
-
-
-
-
-
-
- {field.description}
-
- )}
-
- {hasCanonicalPair && canonicalId && (
-
-
- toggleCanonicalMode(canonicalId)}
- >
-
-
-
-
- {field.mode === 'basic'
- ? 'Switch to manual input'
- : 'Switch to selector'}
-
-
- )}
-
- {field.type === 'selector' && field.selectorKey ? (
-
handleFieldChange(field.id, value)}
- credentialId={effectiveCredentialId}
- sourceConfig={sourceConfig}
- configFields={connectorConfig.configFields}
- canonicalModes={canonicalModes}
- disabled={isCreating}
- />
- ) : field.type === 'dropdown' && field.options ? (
- ({
- label: opt.label,
- value: opt.id,
- }))}
- value={
- typeof sourceConfig[field.id] === 'string'
- ? (sourceConfig[field.id] as string) || undefined
- : undefined
- }
- onChange={(value) => handleFieldChange(field.id, value)}
- placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
- />
- ) : (
- handleFieldChange(field.id, e.target.value)}
- placeholder={field.placeholder}
- />
- )}
-
- )
- })}
+
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
-
-
Metadata Tags
- {connectorConfig.tagDefinitions.map((tagDef) => (
-
toggleTagDefinition(tagDef.id)}
- onKeyDown={(event) => {
- if (event.target !== event.currentTarget) return
- handleKeyboardActivation(event, () => toggleTagDefinition(tagDef.id))
- }}
- >
-
e.stopPropagation()}
- onCheckedChange={(checked) => {
- setDisabledTagIds((prev) => {
- const next = new Set(prev)
- if (checked) {
- next.delete(tagDef.id)
- } else {
- next.add(tagDef.id)
- }
- return next
- })
+
+
+ {connectorConfig.tagDefinitions.map((tagDef) => (
+
toggleTagDefinition(tagDef.id)}
+ onKeyDown={(event) => {
+ if (event.target !== event.currentTarget) return
+ handleKeyboardActivation(event, () => toggleTagDefinition(tagDef.id))
}}
- />
-
- {tagDef.displayName}
-
-
- ({tagDef.fieldType})
-
-
- ))}
-
+ >
+ e.stopPropagation()}
+ onCheckedChange={(checked) => {
+ setDisabledTagIds((prev) => {
+ const next = new Set(prev)
+ if (checked) {
+ next.delete(tagDef.id)
+ } else {
+ next.add(tagDef.id)
+ }
+ return next
+ })
+ }}
+ />
+
+ {tagDef.displayName}
+
+
+ ({tagDef.fieldType})
+
+
+ ))}
+
+
)}
-
- Sync Frequency
+
setSyncInterval(Number(val))}
@@ -487,12 +405,10 @@ export function AddConnectorModal({
))}
-
+
- {error && (
-
{error}
- )}
-
+
{error}
+ >
) : null}
@@ -565,8 +481,8 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
- {config.name}
- {config.description}
+ {config.name}
+ {config.description}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
index 547daf19d8c..4b6967a3552 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
@@ -12,7 +12,6 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
- Label,
Loader,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -22,10 +21,6 @@ import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hook
const logger = createLogger('AddDocumentsModal')
-interface FileWithPreview extends File {
- preview: string
-}
-
interface AddDocumentsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -45,7 +40,7 @@ export function AddDocumentsModal({
}: AddDocumentsModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
- const [files, setFiles] = useState([])
+ const [files, setFiles] = useState([])
const [fileError, setFileError] = useState(null)
const [retryingIndexes, setRetryingIndexes] = useState>(() => new Set())
@@ -53,16 +48,6 @@ export function AddDocumentsModal({
workspaceId,
})
- useEffect(() => {
- return () => {
- files.forEach((file) => {
- if (file.preview) {
- URL.revokeObjectURL(file.preview)
- }
- })
- }
- }, [files])
-
useEffect(() => {
if (open) {
setFiles([])
@@ -97,7 +82,7 @@ export function AddDocumentsModal({
if (!selectedFiles || selectedFiles.length === 0) return
try {
- const newFiles: FileWithPreview[] = []
+ const newFiles: File[] = []
let hasError = false
for (const file of selectedFiles) {
@@ -108,11 +93,7 @@ export function AddDocumentsModal({
continue
}
- const fileWithPreview = Object.assign(file, {
- preview: URL.createObjectURL(file),
- }) as FileWithPreview
-
- newFiles.push(fileWithPreview)
+ newFiles.push(file)
}
if (!hasError && newFiles.length > 0) {
@@ -125,10 +106,7 @@ export function AddDocumentsModal({
}
const removeFile = (index: number) => {
- setFiles((prev) => {
- URL.revokeObjectURL(prev[index].preview)
- return prev.filter((_, i) => i !== index)
- })
+ setFiles((prev) => prev.filter((_, i) => i !== index))
}
const handleRetryFile = async (index: number) => {
@@ -172,88 +150,80 @@ export function AddDocumentsModal({
handleOpenChange(false)}>New Documents
-
-
- {fileError && (
-
{fileError}
- )}
-
-
-
- {files.length > 0 && (
-
-
Selected Files
-
- {files.map((file, index) => {
- const fileStatus = uploadProgress.fileStatuses?.[index]
- const isFailed = fileStatus?.status === 'failed'
- const isRetrying = retryingIndexes.has(index)
- const isProcessing = fileStatus?.status === 'uploading' || isRetrying
-
- return (
-
-
- {file.name}
-
-
- {formatFileSize(file.size)}
-
-
- {isProcessing ? (
-
- ) : (
- <>
- {isFailed && (
-
handleRetryFile(index)}
- disabled={isUploading}
- >
-
-
- )}
-
removeFile(index)}
- disabled={isUploading}
- >
-
-
- >
+
+
+ {files.length > 0 && (
+
+
+ {files.map((file, index) => {
+ const fileStatus = uploadProgress.fileStatuses?.[index]
+ const isFailed = fileStatus?.status === 'failed'
+ const isRetrying = retryingIndexes.has(index)
+ const isProcessing = fileStatus?.status === 'uploading' || isRetrying
+
+ return (
+
+
+ {file.name}
+
+
+ {formatFileSize(file.size)}
+
+
+ {isProcessing ? (
+
+ ) : (
+ <>
+ {isFailed && (
+ handleRetryFile(index)}
+ disabled={isUploading}
+ >
+
+
)}
-
-
- )
- })}
-
-
- )}
-
-
+
removeFile(index)}
+ disabled={isUploading}
+ >
+
+
+ >
+ )}
+
+
+ )
+ })}
+
+
+ )}
+
{uploadError && {uploadError.message} }
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx
index 2716a64d172..38f87b5948e 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -9,14 +9,13 @@ import {
ChipInput,
ChipModal,
ChipModalBody,
+ ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
- Label,
Trash,
} from '@/components/emcn'
-import { requestJson } from '@/lib/api/client/request'
-import { getTagUsageContract, type TagUsageData } from '@/lib/api/contracts/knowledge'
+import type { TagUsageData } from '@/lib/api/contracts/knowledge'
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
@@ -24,7 +23,11 @@ import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
-import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/kb/knowledge'
+import {
+ useCreateTagDefinition,
+ useDeleteTagDefinition,
+ useTagUsageQuery,
+} from '@/hooks/queries/kb/knowledge'
const logger = createLogger('BaseTagsModal')
@@ -58,7 +61,7 @@ function DocumentList({ documents, totalCount }: DocumentListProps) {
{doc.tagValue && (
<>
-
+
{doc.tagValue}
>
@@ -67,7 +70,7 @@ function DocumentList({ documents, totalCount }: DocumentListProps) {
)
})}
{hasMore && (
-
+
and {totalCount - displayLimit} more documents
)}
@@ -91,33 +94,15 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState
(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
- const [tagUsageData, setTagUsageData] = useState([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
})
- const fetchTagUsage = useCallback(async () => {
- if (!knowledgeBaseId) return
-
- try {
- const result = await requestJson(getTagUsageContract, {
- params: { id: knowledgeBaseId },
- })
- if (result.success) {
- setTagUsageData(result.data)
- }
- } catch (error) {
- logger.error('Error fetching tag usage:', error)
- }
- }, [knowledgeBaseId])
-
- useEffect(() => {
- if (open) {
- fetchTagUsage()
- }
- }, [open, fetchTagUsage])
+ const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(knowledgeBaseId, {
+ enabled: open,
+ })
const getTagUsage = (tagSlot: string): TagUsageData => {
return (
@@ -132,13 +117,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
const handleDeleteTagClick = async (tag: TagDefinition) => {
setSelectedTag(tag)
- await fetchTagUsage()
+ await refetchTagUsage()
setDeleteTagDialogOpen(true)
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
- await fetchTagUsage()
+ await refetchTagUsage()
setViewDocumentsDialogOpen(true)
}
@@ -208,8 +193,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
fieldType: createTagForm.fieldType,
})
- await fetchTagUsage()
-
setCreateTagForm({
displayName: '',
fieldType: 'text',
@@ -220,6 +203,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}
}
+ const closeDeleteTagDialog = () => {
+ setDeleteTagDialogOpen(false)
+ setSelectedTag(null)
+ }
+
const confirmDeleteTag = async () => {
if (!selectedTag) return
@@ -229,10 +217,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
tagDefinitionId: selectedTag.id,
})
- await fetchTagUsage()
-
- setDeleteTagDialogOpen(false)
- setSelectedTag(null)
+ closeDeleteTagDialog()
} catch (error) {
logger.error('Error deleting tag definition:', error)
}
@@ -261,15 +246,18 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
-
-
-
+
Tags:{' '}
{kbTagDefinitions.length} defined
-
-
+ >
+ }
+ >
+
{kbTagDefinitions.length === 0 && !isCreatingTag && (
@@ -299,7 +287,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
-
+
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
@@ -331,10 +319,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{isCreatingTag && (
-
- Tag Name
+
setCreateTagForm({ ...createTagForm, displayName: e.target.value })
@@ -352,27 +343,25 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}
}}
/>
- {tagNameConflict && (
-
- A tag with this name already exists
-
- )}
-
-
-
- Type
+
+
+
setCreateTagForm({ ...createTagForm, fieldType: value })}
placeholder='Select type'
/>
- {!hasAvailableSlots(createTagForm.fieldType) && (
-
- No available slots for this type. Choose a different type.
-
- )}
-
+
@@ -394,7 +383,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
)}
-
+
{
- setDeleteTagDialogOpen(open)
- if (!open) setSelectedTag(null)
+ onOpenChange={(openState) => {
+ if (openState) {
+ setDeleteTagDialogOpen(true)
+ } else {
+ closeDeleteTagDialog()
+ }
}}
srTitle='Delete Tag'
title='Delete Tag'
@@ -432,13 +424,12 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}}
>
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
-
- Affected documents:
+
-
+
)}
@@ -453,7 +444,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
Documents using "{selectedTag?.displayName}"
-
+
{selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
new file mode 100644
index 00000000000..43383449ac6
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import { ArrowLeftRight, Info } from 'lucide-react'
+import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@/components/emcn'
+import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
+import type {
+ ConfigFieldMap,
+ ConfigFieldValue,
+} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
+import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
+import type { SelectorKey } from '@/hooks/selectors/types'
+
+export interface ConnectorConfigFieldsProps {
+ /** Registry definition whose `configFields` drive the rendered rows. */
+ connectorConfig: ConnectorConfig
+ /** Current values keyed by field ID. */
+ sourceConfig: ConfigFieldMap
+ /** OAuth credential backing selector fields, when available. */
+ credentialId: string | null
+ /** Canonical-pair groups keyed by `canonicalParamId`. */
+ canonicalGroups: Map
+ /** Active mode per canonical pair. */
+ canonicalModes: Record
+ /** Visibility predicate honoring `condition` / canonical mode. */
+ isFieldVisible: (field: ConnectorConfigField) => boolean
+ /** Field value change handler. */
+ onFieldChange: (fieldId: string, value: ConfigFieldValue) => void
+ /** Swaps a canonical pair between selector and manual input. */
+ onToggleCanonicalMode: (canonicalId: string) => void
+ /** Disables selector fields during submission. */
+ disabled: boolean
+}
+
+/**
+ * Renders the connector's dynamic configuration fields as canonical
+ * `ChipModalField` rows. Shared by the add- and edit-connector modals so the
+ * label + info tooltip + canonical-pair toggle + selector/dropdown/input
+ * switch stays identical in both flows.
+ */
+export function ConnectorConfigFields({
+ connectorConfig,
+ sourceConfig,
+ credentialId,
+ canonicalGroups,
+ canonicalModes,
+ isFieldVisible,
+ onFieldChange,
+ onToggleCanonicalMode,
+ disabled,
+}: ConnectorConfigFieldsProps) {
+ return (
+ <>
+ {connectorConfig.configFields.map((field) => {
+ if (!isFieldVisible(field)) return null
+
+ const canonicalId = field.canonicalParamId
+ const hasCanonicalPair =
+ canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
+
+ return (
+ event.preventDefault()}
+ >
+
+
+ {field.title}
+ {field.required && * }
+
+ {field.description && (
+
+
+
+
+
+
+ {field.description}
+
+ )}
+
+ {hasCanonicalPair && canonicalId && (
+
+
+ onToggleCanonicalMode(canonicalId)}
+ >
+
+
+
+
+ {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
+
+
+ )}
+
+ }
+ >
+ {field.type === 'selector' && field.selectorKey ? (
+ onFieldChange(field.id, value)}
+ credentialId={credentialId}
+ sourceConfig={sourceConfig}
+ configFields={connectorConfig.configFields}
+ canonicalModes={canonicalModes}
+ disabled={disabled}
+ />
+ ) : field.type === 'dropdown' && field.options ? (
+ ({
+ label: opt.label,
+ value: opt.id,
+ }))}
+ value={
+ typeof sourceConfig[field.id] === 'string'
+ ? (sourceConfig[field.id] as string) || undefined
+ : undefined
+ }
+ onChange={(value) => onFieldChange(field.id, value)}
+ placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
+ />
+ ) : (
+ onFieldChange(field.id, e.target.value)}
+ placeholder={field.placeholder}
+ />
+ )}
+
+ )
+ })}
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/index.ts
new file mode 100644
index 00000000000..17b1192f432
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/index.ts
@@ -0,0 +1 @@
+export { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
index 5bb2470bb7e..3e2b9fea59b 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
@@ -15,15 +15,7 @@ import {
Trash,
XCircle,
} from 'lucide-react'
-import {
- Badge,
- Button,
- Checkbox,
- ChipConfirmModal,
- Loader,
- Skeleton,
- Tooltip,
-} from '@/components/emcn'
+import { Badge, Button, Checkbox, ChipConfirmModal, Loader, Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth'
@@ -204,22 +196,7 @@ export function ConnectorsSection({
{error && {error}
}
{isLoading ? (
-
- {Array.from({ length: 2 }).map((_, i) => (
-
- ))}
-
+
) : connectors.length === 0 ? (
No connected sources yet. Connect an external source to automatically sync documents.
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
index 38df4f7b435..458a7719cba 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx
@@ -2,24 +2,23 @@
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ArrowLeftRight, ExternalLink, Info, RotateCcw } from 'lucide-react'
+import { ExternalLink, RotateCcw } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
- ChipCombobox,
- ChipInput,
ChipModal,
ChipModalBody,
+ ChipModalError,
+ ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipModalTabs,
- Label,
Skeleton,
Tooltip,
} from '@/components/emcn'
import { getSubscriptionAccessState } from '@/lib/billing/client'
-import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
+import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields'
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
import type {
@@ -38,7 +37,6 @@ import {
useUpdateConnector,
} from '@/hooks/queries/kb/connectors'
import { useSubscriptionData } from '@/hooks/queries/subscription'
-import type { SelectorKey } from '@/hooks/selectors/types'
const logger = createLogger('EditConnectorModal')
@@ -281,7 +279,7 @@ export function EditConnectorModal({
Edit {displayName}
-
+
{activeTab === 'settings' ? (
@@ -359,98 +358,22 @@ function SettingsTab({
error,
}: SettingsTabProps) {
return (
-
- {connectorConfig?.configFields.map((field) => {
- if (!isFieldVisible(field)) return null
-
- const canonicalId = field.canonicalParamId
- const hasCanonicalPair =
- canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
-
- return (
-
-
-
-
- {field.title}
- {field.required && * }
-
- {field.description && (
-
-
-
-
-
-
- {field.description}
-
- )}
-
- {hasCanonicalPair && canonicalId && (
-
-
- onToggleCanonicalMode(canonicalId)}
- >
-
-
-
-
- {field.mode === 'basic' ? 'Switch to manual input' : 'Switch to selector'}
-
-
- )}
-
- {field.type === 'selector' && field.selectorKey ? (
-
onFieldChange(field.id, value)}
- credentialId={credentialId}
- sourceConfig={sourceConfig}
- configFields={connectorConfig.configFields}
- canonicalModes={canonicalModes}
- disabled={isSaving}
- />
- ) : field.type === 'dropdown' && field.options ? (
- ({
- label: opt.label,
- value: opt.id,
- }))}
- value={
- typeof sourceConfig[field.id] === 'string'
- ? (sourceConfig[field.id] as string) || undefined
- : undefined
- }
- onChange={(value) => onFieldChange(field.id, value)}
- placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
- />
- ) : (
- onFieldChange(field.id, e.target.value)}
- placeholder={field.placeholder}
- />
- )}
-
- )
- })}
+ <>
+ {connectorConfig && (
+
+ )}
-
- Sync Frequency
+
setSyncInterval(Number(val))}
@@ -466,10 +389,10 @@ function SettingsTab({
))}
-
+
- {error &&
{error}
}
-
+ {error}
+ >
)
}
@@ -497,7 +420,7 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
if (isLoading) {
return (
-
+
@@ -507,7 +430,7 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) {
}
return (
-
+
setFilter(val as 'active' | 'excluded')}>
Active ({counts.active})
Excluded ({counts.excluded})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
index 9c4a1f624d5..b1ea25f26b5 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
@@ -1,69 +1,45 @@
-import { Skeleton } from '@/components/emcn'
+'use client'
-const SKELETON_ROW_COUNT = 5
-const COLUMN_COUNT = 7
+import { Plus } from '@/components/emcn'
+import { Database } from '@/components/emcn/icons'
+import {
+ type BreadcrumbItem,
+ type ChromeActionSpec,
+ ResourceChromeFallback,
+} from '@/app/workspace/[workspaceId]/components'
+
+const noop = () => {}
+
+const COLUMNS = [
+ { id: 'name', header: 'Name', widthMultiplier: 0.8 },
+ { id: 'size', header: 'Size', widthMultiplier: 0.75 },
+ { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 },
+ { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 },
+ { id: 'uploaded', header: 'Uploaded' },
+ { id: 'status', header: 'Status', widthMultiplier: 0.75 },
+ { id: 'tags', header: 'Tags' },
+]
+
+const ACTIONS: ChromeActionSpec[] = [
+ { text: 'New connector', icon: Plus },
+ { text: 'New documents', icon: Plus, variant: 'primary' },
+]
+
+const BREADCRUMBS: BreadcrumbItem[] = [
+ { label: 'Knowledge Base', icon: Database, onClick: noop },
+ { label: '…', terminal: true },
+]
export default function KnowledgeBaseLoading() {
return (
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, i) => (
-
-
-
- ))}
-
-
-
- {Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
index 1cda4ec9917..e1d4c1814ca 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx
@@ -1,10 +1,10 @@
'use client'
-import { memo, useEffect, useRef, useState } from 'react'
+import { memo, useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { RotateCcw, X } from 'lucide-react'
+import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
@@ -21,7 +21,6 @@ import {
ChipModalHeader,
ChipTextarea,
type ComboboxOption,
- Label,
Loader,
} from '@/components/emcn'
import type { StrategyOptions } from '@/lib/chunkers/types'
@@ -33,10 +32,6 @@ import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/
const logger = createLogger('CreateBaseModal')
-interface FileWithPreview extends File {
- preview: string
-}
-
interface CreateBaseModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -132,11 +127,8 @@ export const CreateBaseModal = memo(function CreateBaseModal({
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
const [submitStatus, setSubmitStatus] = useState(null)
- const [files, setFiles] = useState([])
+ const [files, setFiles] = useState([])
const [fileError, setFileError] = useState(null)
- const [retryingIndexes, setRetryingIndexes] = useState>(() => new Set())
-
- const scrollContainerRef = useRef(null)
const { uploadFiles, isUploading, uploadProgress, uploadError, clearError } = useKnowledgeUpload({
workspaceId,
@@ -149,16 +141,6 @@ export const CreateBaseModal = memo(function CreateBaseModal({
onOpenChange(open)
}
- useEffect(() => {
- return () => {
- files.forEach((file) => {
- if (file.preview) {
- URL.revokeObjectURL(file.preview)
- }
- })
- }
- }, [files])
-
const {
register,
handleSubmit,
@@ -191,7 +173,6 @@ export const CreateBaseModal = memo(function CreateBaseModal({
setSubmitStatus(null)
setFileError(null)
setFiles([])
- setRetryingIndexes(new Set())
reset({
name: '',
description: '',
@@ -212,7 +193,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
if (!selectedFiles || selectedFiles.length === 0) return
try {
- const newFiles: FileWithPreview[] = []
+ const newFiles: File[] = []
let hasError = false
for (const file of selectedFiles) {
@@ -223,11 +204,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
continue
}
- const fileWithPreview = Object.assign(file, {
- preview: URL.createObjectURL(file),
- }) as FileWithPreview
-
- newFiles.push(fileWithPreview)
+ newFiles.push(file)
}
if (!hasError && newFiles.length > 0) {
@@ -240,10 +217,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
}
const removeFile = (index: number) => {
- setFiles((prev) => {
- URL.revokeObjectURL(prev[index].preview)
- return prev.filter((_, i) => i !== index)
- })
+ setFiles((prev) => prev.filter((_, i) => i !== index))
}
const isSubmitting =
@@ -302,7 +276,6 @@ export const CreateBaseModal = memo(function CreateBaseModal({
}
}
- files.forEach((file) => URL.revokeObjectURL(file.preview))
setFiles([])
handleClose(false)
@@ -321,259 +294,215 @@ export const CreateBaseModal = memo(function CreateBaseModal({
+
+ )}
{uploadError?.message || submitStatus?.message}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index 41b1e3a192e..6bc272ffddc 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -4,12 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import type { ChipDropdownOption } from '@/components/emcn'
-import { Button, ChipDropdown, Tooltip } from '@/components/emcn'
+import { Button, ChipDropdown, Plus, Tooltip } from '@/components/emcn'
import { Database } from '@/components/emcn/icons'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type {
- CreateAction,
FilterTag,
+ ResourceAction,
ResourceCell,
ResourceColumn,
ResourceRow,
@@ -386,12 +386,16 @@ export function Knowledge() {
const canEdit = userPermissions.canEdit === true
- const createAction: CreateAction = useMemo(
- () => ({
- label: 'New base',
- onClick: handleOpenCreateModal,
- disabled: !canEdit,
- }),
+ const headerActions: ResourceAction[] = useMemo(
+ () => [
+ {
+ text: 'New base',
+ icon: Plus,
+ onSelect: handleOpenCreateModal,
+ disabled: !canEdit,
+ variant: 'primary',
+ },
+ ],
[handleOpenCreateModal, canEdit]
)
@@ -552,21 +556,23 @@ export function Knowledge() {
return (
<>
-
+
+
+
+
+
-
+const COLUMNS = [
+ { id: 'name', header: 'Name' },
+ { id: 'documents', header: 'Documents', widthMultiplier: 0.6 },
+ { id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 },
+ { id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 },
+ { id: 'created', header: 'Created' },
+ { id: 'owner', header: 'Owner' },
+ { id: 'updated', header: 'Last Updated' },
+]
-
+const ACTIONS: ChromeActionSpec[] = [{ text: 'New base', icon: Plus, variant: 'primary' }]
-
-
-
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, i) => (
-
-
-
- ))}
-
-
-
- {Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
+export default function KnowledgeLoading() {
+ return (
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx
index fcb7db1237e..3ecaa4c2251 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx
@@ -3,7 +3,7 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
-import { Loader, Skeleton } from '@/components/emcn'
+import { Loader } from '@/components/emcn'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
import { useWorkflows } from '@/hooks/queries/workflows'
@@ -27,93 +27,8 @@ interface WorkflowExecution {
overallSuccessRate: number
}
-const SKELETON_BAR_HEIGHTS = [
- 45, 72, 38, 85, 52, 68, 30, 90, 55, 42, 78, 35, 88, 48, 65, 28, 82, 58, 40, 75, 32, 95, 50, 70,
-]
-
-function GraphCardSkeleton({ title }: { title: string }) {
- return (
-
-
-
- {title}
-
-
-
-
-
-
- {SKELETON_BAR_HEIGHTS.map((height, i) => (
-
- ))}
-
-
-
-
- )
-}
-
-function WorkflowRowSkeleton() {
- return (
-
- )
-}
-
-function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
- return (
-
-
-
-
- Workflow
-
- Logs
-
- Success Rate
-
-
-
-
- {Array.from({ length: rowCount }).map((_, i) => (
-
- ))}
-
-
- )
-}
-
-function DashboardSkeleton() {
- return (
-
- )
+function DashboardFallback() {
+ return
}
interface DashboardProps {
@@ -449,7 +364,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
}
if (isLoading) {
- return
+ return
}
if (error) {
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx
index 0ecfcd77fd3..4e65092dd69 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx
@@ -10,16 +10,14 @@ import {
Button,
ChipCombobox,
ChipConfirmModal,
+ ChipInput,
ChipModal,
ChipModalBody,
+ ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipModalTabs,
- Input as EmcnInput,
- Label,
Skeleton,
- TagInput,
- type TagItem,
} from '@/components/emcn'
import { SlackIcon } from '@/components/icons'
import type {
@@ -29,7 +27,6 @@ import type {
} from '@/lib/api/contracts/notifications'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
-import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
useCreateNotification,
@@ -201,8 +198,6 @@ export const NotificationSettings = memo(function NotificationSettings({
errorCountThreshold: 10,
})
- const [emailItems, setEmailItems] = useState
([])
-
const [formErrors, setFormErrors] = useState>({})
const { data: subscriptions = [], isLoading } = useNotifications(open ? workspaceId : undefined)
@@ -267,7 +262,6 @@ export const NotificationSettings = memo(function NotificationSettings({
})
setFormErrors({})
setEditingId(null)
- setEmailItems([])
}, [])
const handleClose = useCallback(() => {
@@ -277,45 +271,10 @@ export const NotificationSettings = memo(function NotificationSettings({
onOpenChange(false)
}, [onOpenChange, resetForm])
- const addEmail = useCallback(
- (email: string): boolean => {
- if (!email.trim()) return false
-
- const normalized = email.trim().toLowerCase()
- const validation = quickValidateEmail(normalized)
-
- if (emailItems.some((item) => item.value === normalized)) {
- return false
- }
-
- setEmailItems((prev) => [...prev, { value: normalized, isValid: validation.isValid }])
-
- if (validation.isValid) {
- setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
- setFormData((prev) => ({
- ...prev,
- emailRecipients: [...prev.emailRecipients, normalized],
- }))
- }
-
- return validation.isValid
- },
- [emailItems]
- )
-
- const handleRemoveEmailItem = useCallback(
- (_value: string, index: number, isValid: boolean) => {
- const itemToRemove = emailItems[index]
- setEmailItems((prev) => prev.filter((_, i) => i !== index))
- if (isValid && itemToRemove) {
- setFormData((prev) => ({
- ...prev,
- emailRecipients: prev.emailRecipients.filter((e) => e !== itemToRemove.value),
- }))
- }
- },
- [emailItems]
- )
+ const handleEmailRecipientsChange = useCallback((next: string[]) => {
+ setFormData((prev) => ({ ...prev, emailRecipients: next }))
+ setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
+ }, [])
const validateForm = (): boolean => {
const errors: Record = {}
@@ -353,12 +312,6 @@ export const NotificationSettings = memo(function NotificationSettings({
} else if (formData.emailRecipients.length > 10) {
errors.emailRecipients = 'Maximum 10 email recipients allowed'
}
- const invalidEmailValues = emailItems
- .filter((item) => !item.isValid)
- .map((item) => item.value)
- if (invalidEmailValues.length > 0) {
- errors.emailRecipients = `Invalid email addresses: ${invalidEmailValues.join(', ')}`
- }
}
if (activeTab === 'slack') {
@@ -546,9 +499,6 @@ export const NotificationSettings = memo(function NotificationSettings({
inactivityHours: subscription.alertConfig?.inactivityHours || 24,
errorCountThreshold: subscription.alertConfig?.errorCountThreshold || 10,
})
- setEmailItems(
- (subscription.emailRecipients || []).map((email) => ({ value: email, isValid: true }))
- )
setShowForm(true)
}
@@ -673,57 +623,53 @@ export const NotificationSettings = memo(function NotificationSettings({
{activeTab === 'webhook' && (
<>
-
-
Webhook URL
-
{
- setFormData({ ...formData, webhookUrl: e.target.value })
- setFormErrors({ ...formErrors, webhookUrl: '' })
- }}
- />
- {formErrors.webhookUrl && (
- {formErrors.webhookUrl}
- )}
-
-
- Secret (optional)
- setFormData({ ...formData, webhookSecret: e.target.value })}
- />
-
+ {
+ setFormData({ ...formData, webhookUrl: value })
+ setFormErrors({ ...formErrors, webhookUrl: '' })
+ }}
+ error={formErrors.webhookUrl}
+ />
+ setFormData({ ...formData, webhookSecret: value })}
+ />
>
)}
{activeTab === 'email' && (
-
-
Email Recipients
-
addEmail(value)}
- onRemove={handleRemoveEmailItem}
- placeholder='Enter emails'
- placeholderWithTags='Add email'
- />
- {formErrors.emailRecipients && (
-
- {formErrors.emailRecipients}
-
- )}
-
+
)}
{activeTab === 'slack' && (
<>
-
-
Slack Account
+
{isLoadingSlackAccounts ? (
) : slackAccounts.length === 0 ? (
@@ -761,15 +707,9 @@ export const NotificationSettings = memo(function NotificationSettings({
placeholder='Select account...'
/>
)}
- {formErrors.slackAccountId && (
-
- {formErrors.slackAccountId}
-
- )}
-
+
{slackAccounts.length > 0 && (
-
- Channel
+
-
+
)}
>
)}
-
-
Log Level Filters
+
({
label: level.charAt(0).toUpperCase() + level.slice(1),
@@ -830,13 +774,14 @@ export const NotificationSettings = memo(function NotificationSettings({
showAllOption
allOptionLabel='All levels'
/>
- {formErrors.levelFilter && (
- {formErrors.levelFilter}
- )}
-
-
-
-
Trigger Type Filters
+
+
+
({
label: t.label,
@@ -881,13 +826,9 @@ export const NotificationSettings = memo(function NotificationSettings({
showAllOption
allOptionLabel='All triggers'
/>
- {formErrors.triggerFilter && (
- {formErrors.triggerFilter}
- )}
-
+
-
- Include in Payload
+
-
-
-
-
Rule
+
+
+
r.value === formData.alertRule)?.description}
+ >
({
value: rule.value,
@@ -974,18 +919,20 @@ export const NotificationSettings = memo(function NotificationSettings({
onChange={(value) => setFormData({ ...formData, alertRule: value as AlertRule })}
placeholder='Select rule'
/>
-
- {ALERT_RULES.find((r) => r.value === formData.alertRule)?.description}
-
-
+
{formData.alertRule === 'consecutive_failures' && (
-
-
Failure Count
-
+
setFormData({
@@ -994,22 +941,23 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.consecutiveFailures && (
-
- {formErrors.consecutiveFailures}
-
- )}
-
+
)}
{formData.alertRule === 'failure_rate' && (
-
-
-
Failure Rate (%)
-
+
+
setFormData({
@@ -1018,18 +966,19 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.failureRatePercent && (
-
- {formErrors.failureRatePercent}
-
- )}
-
-
-
Window (hours)
-
+
+
setFormData({
@@ -1038,20 +987,22 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.windowHours && (
- {formErrors.windowHours}
- )}
-
+
)}
{formData.alertRule === 'latency_threshold' && (
-
-
Duration Threshold (seconds)
-
+
setFormData({
@@ -1060,22 +1011,23 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.durationThresholdMs && (
-
- {formErrors.durationThresholdMs}
-
- )}
-
+
)}
{formData.alertRule === 'latency_spike' && (
-
-
-
Above Average (%)
-
+
+
setFormData({
@@ -1084,18 +1036,19 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.latencySpikePercent && (
-
- {formErrors.latencySpikePercent}
-
- )}
-
-
-
Window (hours)
-
+
+
setFormData({
@@ -1104,21 +1057,23 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.windowHours && (
- {formErrors.windowHours}
- )}
-
+
)}
{formData.alertRule === 'cost_threshold' && (
-
-
Cost Threshold ($)
-
+
setFormData({
@@ -1127,21 +1082,21 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.costThresholdDollars && (
-
- {formErrors.costThresholdDollars}
-
- )}
-
+
)}
{formData.alertRule === 'no_activity' && (
-
-
Inactivity Period (hours)
-
+
setFormData({
@@ -1150,22 +1105,23 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.inactivityHours && (
-
- {formErrors.inactivityHours}
-
- )}
-
+
)}
{formData.alertRule === 'error_count' && (
-
-
-
Error Count
-
+
+
setFormData({
@@ -1174,18 +1130,19 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.errorCountThreshold && (
-
- {formErrors.errorCountThreshold}
-
- )}
-
-
-
Window (hours)
-
+
+
setFormData({
@@ -1194,10 +1151,7 @@ export const NotificationSettings = memo(function NotificationSettings({
})
}
/>
- {formErrors.windowHours && (
- {formErrors.windowHours}
- )}
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx b/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
index f8e038af1b2..518c280d2cf 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx
@@ -1,71 +1,39 @@
-import { Skeleton } from '@/components/emcn'
+'use client'
-const SKELETON_ROW_COUNT = 5
-const COLUMN_COUNT = 6
+import { Bell, Library, RefreshCw } from '@/components/emcn'
+import { Download } from '@/components/emcn/icons'
+import {
+ type ChromeActionSpec,
+ ResourceChromeFallback,
+} from '@/app/workspace/[workspaceId]/components'
+
+const COLUMNS = [
+ { id: 'workflow', header: 'Workflow' },
+ { id: 'date', header: 'Date' },
+ { id: 'status', header: 'Status' },
+ { id: 'cost', header: 'Cost' },
+ { id: 'trigger', header: 'Trigger' },
+ { id: 'duration', header: 'Duration' },
+]
+
+const ACTIONS: ChromeActionSpec[] = [
+ { text: 'Export', icon: Download },
+ { text: 'Notifications', icon: Bell },
+ { text: 'Refresh', icon: RefreshCw },
+ { text: 'Logs', active: true },
+ { text: 'Dashboard' },
+]
export default function LogsLoading() {
return (
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, i) => (
-
-
-
- ))}
-
-
-
- {Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
index e53e7d8eaf0..1e6d70d4d0a 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
@@ -46,17 +46,13 @@ import {
} from '@/lib/logs/search-suggestions'
import type {
FilterTag,
- HeaderAction,
+ ResourceAction,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
-import {
- ResourceHeader,
- ResourceOptionsBar,
- ResourceTable,
-} from '@/app/workspace/[workspaceId]/components'
+import { Resource } from '@/app/workspace/[workspaceId]/components'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -759,7 +755,6 @@ export default function Logs() {
id: log.id,
cells: {
workflow: {
- icon:
,
label: workflowName,
},
date: { label: `${formattedDate.compactDate} ${formattedDate.compactTime}` },
@@ -1076,33 +1071,33 @@ export default function Logs() {
const refreshIcon = isVisuallyRefreshing ? SpinningRefreshCw : RefreshCw
- const headerActions = useMemo
(
+ const headerActions = useMemo(
() => [
{
- label: 'Export',
+ text: 'Export',
icon: Download,
- onClick: handleExport,
+ onSelect: handleExport,
disabled: !userPermissions.canEdit || isExporting || logs.length === 0,
},
{
- label: 'Notifications',
+ text: 'Notifications',
icon: Bell,
- onClick: handleOpenNotificationSettings,
+ onSelect: handleOpenNotificationSettings,
},
{
- label: 'Refresh',
+ text: 'Refresh',
icon: refreshIcon,
- onClick: handleRefresh,
+ onSelect: handleRefresh,
disabled: isVisuallyRefreshing,
},
{
- label: 'Logs',
- onClick: () => setViewMode('logs'),
+ text: 'Logs',
+ onSelect: () => setViewMode('logs'),
active: !isDashboardView,
},
{
- label: 'Dashboard',
- onClick: () => setViewMode('dashboard'),
+ text: 'Dashboard',
+ onSelect: () => setViewMode('dashboard'),
active: isDashboardView,
},
],
@@ -1122,14 +1117,16 @@ export default function Logs() {
return (
<>
-
-
-
+
+
- }
+ filter={{
+ content: (
+
+ ),
+ }}
filterTags={filterTags}
/>
{isDashboardView ? (
@@ -1144,7 +1141,7 @@ export default function Logs() {
{sidebarOverlay}
) : (
-
)}
-
+
-
-
Status
+
+
+ Status
-
-
Workflow
+
+ Workflow
-
-
Folder
+
+ Folder
-
-
Trigger
+
+ Trigger
-
-
Time Range
+
+
Time Range
scheduleType !== 'minutes' && scheduleType !== 'hourly',
- [scheduleType]
- )
+ const showTimezone = scheduleType !== 'minutes' && scheduleType !== 'hourly'
const resolvedTimezone = showTimezone ? timezone : 'UTC'
@@ -232,14 +230,12 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
}
}, [computedCron, resolvedTimezone])
- const isFormValid = useMemo(
- () =>
- title.trim() &&
+ const isFormValid = Boolean(
+ title.trim() &&
prompt.trim() &&
computedCron &&
schedulePreview &&
- !('error' in schedulePreview),
- [title, prompt, computedCron, schedulePreview]
+ !('error' in schedulePreview)
)
const resetForm = () => {
@@ -261,9 +257,19 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
setSubmitError(null)
}
+ /**
+ * Single close/open handler for every close path (footer Cancel, header X,
+ * Esc, and overlay click). The create-mode instance stays mounted between
+ * opens, so any close must also reset the draft to avoid stale values
+ * reappearing on the next open.
+ */
+ const handleOpenChange = (nextOpen: boolean) => {
+ onOpenChange(nextOpen)
+ if (!nextOpen) resetForm()
+ }
+
const handleClose = () => {
- onOpenChange(false)
- resetForm()
+ handleOpenChange(false)
}
const handleSubmit = async () => {
@@ -305,209 +311,201 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
const modalTitle = isEditing ? 'Edit scheduled task' : 'Create new scheduled task'
return (
-
- onOpenChange(false)}>{modalTitle}
-
-
-
-
Title
+
+ {modalTitle}
+
+ {
+ setTitle(value)
+ if (submitError) setSubmitError(null)
+ }}
+ placeholder='e.g., Daily report generation'
+ autoComplete='off'
+ onSubmit={handleSubmit}
+ />
+
+ {
+ setPrompt(value)
+ if (submitError) setSubmitError(null)
+ }}
+ placeholder='Describe what this scheduled task should do...'
+ minHeight={80}
+ />
+
+
+ setScheduleType(v as ScheduleType)}
+ placeholder='Select frequency'
+ />
+
+
+ {scheduleType === 'minutes' && (
+
{
- setTitle(e.target.value)
- if (submitError) setSubmitError(null)
- }}
- placeholder='e.g., Daily report generation'
- autoComplete='off'
+ type='number'
+ value={minutesInterval}
+ onChange={(e) => setMinutesInterval(e.target.value)}
+ placeholder='15'
+ min={1}
+ max={1440}
/>
-
+
+ )}
-
-
Task description
-
{
- setPrompt(e.target.value)
- if (submitError) setSubmitError(null)
- }}
- placeholder='Describe what this scheduled task should do...'
- className='min-h-[80px]'
- />
-
-
-
-
Run frequency
-
setScheduleType(v as ScheduleType)}
- placeholder='Select frequency'
+ {scheduleType === 'hourly' && (
+
+ setHourlyMinute(e.target.value)}
+ placeholder='0'
+ min={0}
+ max={59}
/>
+
+ )}
+
+ {scheduleType === 'daily' && (
+
+
+
+ )}
+
+ {scheduleType === 'weekly' && (
+
+
+
+
+
+
+
+ )}
- {scheduleType === 'minutes' && (
-
-
Interval (minutes)
+ {scheduleType === 'monthly' && (
+
+
setMinutesInterval(e.target.value)}
- placeholder='15'
+ value={monthlyDay}
+ onChange={(e) => setMonthlyDay(e.target.value)}
+ placeholder='1'
min={1}
- max={1440}
+ max={31}
/>
-
- )}
-
- {scheduleType === 'hourly' && (
-
-
Minute of hour
-
setHourlyMinute(e.target.value)}
- placeholder='0'
- min={0}
- max={59}
- />
-
- )}
-
- {scheduleType === 'daily' && (
-
- )}
-
- {scheduleType === 'weekly' && (
-
- )}
-
- {scheduleType === 'monthly' && (
-
-
-
Day of month
-
setMonthlyDay(e.target.value)}
- placeholder='1'
- min={1}
- max={31}
- />
-
-
-
- )}
+
+
+
+
+
+ )}
- {scheduleType === 'custom' && (
-
-
Cron expression
-
setCronExpression(e.target.value)}
- placeholder='0 9 * * *'
- inputClassName='font-mono'
- autoComplete='off'
- />
-
- )}
-
- {showTimezone && (
-
- )}
+ {scheduleType === 'custom' && (
+
+ setCronExpression(e.target.value)}
+ placeholder='0 9 * * *'
+ inputClassName='font-mono'
+ autoComplete='off'
+ />
+
+ )}
- {!isEditing && (
-
-
+ {showTimezone && (
+
+
+
+ )}
+
+ {!isEditing && (
+
Start date
(optional)
-
-
-
- )}
-
-
-
Lifecycle
-
setLifecycle(value as 'persistent' | 'until_complete')}
- >
- Recurring
- Number of runs
-
-
-
- {lifecycle === 'until_complete' && (
-
-
+ >
+ }
+ >
+
+
+ )}
+
+
+ setLifecycle(value as 'persistent' | 'until_complete')}
+ >
+ Recurring
+ Number of runs
+
+
+
+ {lifecycle === 'until_complete' && (
+
Max runs
(optional)
-
-
setMaxRuns(e.target.value)}
- placeholder='No limit'
- min={1}
- />
-
- )}
-
- {computedCron && schedulePreview && (
-
- {'error' in schedulePreview ? (
-
{schedulePreview.error}
- ) : (
-
-
- {schedulePreview.humanReadable}
+ >
+ }
+ >
+ setMaxRuns(e.target.value)}
+ placeholder='No limit'
+ min={1}
+ />
+
+ )}
+
+ {computedCron && schedulePreview && (
+
+ {'error' in schedulePreview ? (
+
{schedulePreview.error}
+ ) : (
+
+
+ {schedulePreview.humanReadable}
+
+ {schedulePreview.nextRun && (
+
+ Next run:{' '}
+ {schedulePreview.nextRun.toLocaleString(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ })}
- {schedulePreview.nextRun && (
-
- Next run:{' '}
- {schedulePreview.nextRun.toLocaleString(undefined, {
- dateStyle: 'medium',
- timeStyle: 'short',
- })}
-
- )}
-
- )}
-
- )}
-
- {submitError && (
- {submitError}
- )}
-
+ )}
+
+ )}
+
+ )}
+
+
{submitError}
-
-
-
-
-
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, i) => (
-
-
-
- ))}
-
-
-
- {Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
-
-
-
-
- {Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx
index ec8980d2041..65180ff5b4e 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx
@@ -4,11 +4,11 @@ import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { formatAbsoluteDate } from '@sim/utils/formatting'
import { useParams } from 'next/navigation'
-import { ChipCombobox, ChipConfirmModal } from '@/components/emcn'
-import { Calendar } from '@/components/emcn/icons'
+import { Calendar, ChipCombobox, ChipConfirmModal, Plus } from '@/components/emcn'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import type {
FilterTag,
+ ResourceAction,
ResourceColumn,
ResourceRow,
SortConfig,
@@ -360,6 +360,18 @@ export function ScheduledTasks() {
]
)
+ const headerActions: ResourceAction[] = useMemo(
+ () => [
+ {
+ text: 'New scheduled task',
+ icon: Plus,
+ onSelect: () => setIsCreateModalOpen(true),
+ variant: 'primary',
+ },
+ ],
+ []
+ )
+
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (scheduleTypeFilter.length > 0) {
@@ -384,27 +396,26 @@ export function ScheduledTasks() {
return (
<>
- setIsCreateModalOpen(true),
- }}
- search={{
- value: searchQuery,
- onChange: setSearchQuery,
- placeholder: 'Search scheduled tasks...',
- }}
- sort={sortConfig}
- filter={filterContent}
- filterTags={filterTags}
- columns={COLUMNS}
- rows={rows}
- onRowContextMenu={handleRowContextMenu}
- isLoading={isLoading}
- onContextMenu={handleContentContextMenu}
- />
+
+
+
+
+
Payment method
{
@@ -289,22 +287,18 @@ export function InboxSettingsTab() {
The old address will stop receiving emails immediately.
-
-
New email prefix
-
{
- setNewUsername(e.target.value)
- if (editAddressError) setEditAddressError(null)
- }}
- placeholder='e.g., new-acme'
- />
- {editAddressError && (
-
- {editAddressError}
-
- )}
-
+ {
+ setNewUsername(value)
+ if (editAddressError) setEditAddressError(null)
+ }}
+ onSubmit={handleEditAddress}
+ placeholder='e.g., new-acme'
+ error={editAddressError}
+ />
setIsEditAddressOpen(false)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/form-field/form-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/form-field/form-field.tsx
deleted file mode 100644
index cad5381d1d2..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/form-field/form-field.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Label } from '@/components/emcn'
-
-interface FormFieldProps {
- label: string
- children: React.ReactNode
- optional?: boolean
-}
-
-export function FormField({ label, children, optional }: FormFieldProps) {
- return (
-
-
- {label}
- {optional && (
- (optional)
- )}
-
-
{children}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/form-field/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/form-field/index.ts
deleted file mode 100644
index b5a73ed2442..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/form-field/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { FormField } from './form-field'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/index.ts
index 7aea96e753f..787b3822142 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/index.ts
@@ -1,4 +1,3 @@
-export { FormField } from './form-field'
export {
type McpServerFormConfig,
McpServerFormModal,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
index bb2c7911c7e..ec014a17eb9 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx
@@ -9,10 +9,11 @@ import {
ChipInput,
ChipModal,
ChipModalBody,
+ ChipModalError,
+ ChipModalField,
ChipModalFooter,
type ChipModalFooterAction,
ChipModalHeader,
- ChipTextarea,
SecretInput,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -23,7 +24,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { useMcpServerTest } from '@/hooks/queries/mcp'
-import { FormField } from '../form-field/form-field'
const logger = createLogger('McpServerFormModal')
@@ -649,24 +649,25 @@ export function McpServerFormModal({
return (
onOpenChange(false)}>{title}
-
+
{formMode === 'json' ? (
-
-
{
- setJsonInput(e.target.value)
- if (jsonError) setJsonError(null)
- if (testResult) clearTestResult()
- if (submitError) setSubmitError(null)
- }}
- className='min-h-[280px] font-mono text-small leading-5'
- />
- {jsonError && {jsonError}
}
-
+ {
+ setJsonInput(value)
+ if (jsonError) setJsonError(null)
+ if (testResult) clearTestResult()
+ if (submitError) setSubmitError(null)
+ }}
+ placeholder={`{\n "mcpServers": {\n "server-name": {\n "url": "https://...",\n "headers": {\n "X-API-Key": "..."\n }\n }\n }\n}`}
+ minHeight={280}
+ resizable
+ error={jsonError}
+ />
) : (
-
+ <>
-
- {
- if (testResult) clearTestResult()
- if (submitError) setSubmitError(null)
- setFormData((prev) => ({ ...prev, name: e.target.value }))
- }}
- />
-
+
{
+ if (testResult) clearTestResult()
+ if (submitError) setSubmitError(null)
+ setFormData((prev) => ({ ...prev, name: value }))
+ }}
+ placeholder='e.g., My MCP Server'
+ />
-
+
handleInputChange('url', e.target.value)}
onScroll={setUrlScrollLeft}
/>
- {isDomainBlocked && (
-
- Domain not permitted by server policy
-
- )}
-
+
-
+
{(formData.headers || []).map((header, index) => (
))}
-
+
setShowAdvanced((v) => !v)}
- className='mt-1 gap-1 self-start px-0 py-0 text-small'
+ className='gap-1 self-start px-2 py-0 text-small'
>
{showAdvanced ? (
@@ -758,8 +758,8 @@ export function McpServerFormModal({
Advanced settings
{showAdvanced && (
-
-
+ <>
+
({ ...prev, oauthClientId: e.target.value }))
}}
/>
-
-
+
+
({ ...prev, oauthClientSecret: value }))
}}
/>
-
-
- Only needed for servers that don't support automatic client registration.
-
-
+
+ >
)}
-
+ >
)}
- {submitError && {submitError}
}
+ {submitError}
onOpenChange(false)}
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 569aa32ff79..55ed0468db2 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
@@ -6,10 +6,7 @@ import { formatDate } from '@sim/utils/formatting'
import { useParams, useRouter } from 'next/navigation'
import { Button, ChipInput, ChipModalTabs } from '@/components/emcn'
import { Folder, Search, Workflow } from '@/components/emcn/icons'
-import {
- type ColumnOption,
- SortDropdown,
-} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options-bar'
+import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
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 ced01d7b50a..b36fe42ba98 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
@@ -205,7 +205,6 @@ function WorkspaceVariableRow({
name={`workspace_env_value_${envKey}_${generateShortId()}`}
/>
onViewDetails(envKey)}
disabled={!hasCredential}
className={cn('ml-2', !hasCredential && 'opacity-40')}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
index 1ea8749da87..bb07a332c48 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx
@@ -109,11 +109,7 @@ export function OrganizationMemberLists({
const buildActionsMenu = (children: React.ReactNode) => (
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx
index 2cf87bb1f04..ea442e0e9de 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx
@@ -232,7 +232,7 @@ export function Teammates() {
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 51d61522792..51b1a33a46c 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
@@ -103,6 +103,7 @@ export function CreateWorkflowMcpServerModal({
searchPlaceholder='Search workflows...'
disabled={createServerMutation.isPending}
fullWidth
+ dropdownWidth='trigger'
align='start'
displayLabel={
selectedWorkflowIds.length > 0
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 dd86d5158a3..79d517b888b 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
@@ -814,6 +814,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
searchPlaceholder='Search workflows...'
disabled={addToolMutation.isPending}
fullWidth
+ dropdownWidth='trigger'
align='start'
displayLabel={selectedWorkflow?.name}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx
index 7d3d5db1d06..968b63b3819 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx
@@ -220,7 +220,6 @@ export function SkillImport({ onImport }: SkillImportProps) {
className='flex-1'
/>
-
interface NewColumnDropdownProps {
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
* the in-table column-header `` trigger. Same dropdown content either way. */
@@ -49,10 +50,11 @@ export function NewColumnDropdown({
{trigger === 'header' ? (
-
- {HEADER_ADD_COLUMN_ICON}
- New column
-
+
+
+ New column
+
+
) : (
void
table: TableInfo
@@ -37,18 +32,6 @@ export interface RowModalProps {
onSuccess: () => void
}
-function createInitialRowData(columns: ColumnDefinition[]): Record {
- const initial: Record = {}
- columns.forEach((col) => {
- if (col.type === 'boolean') {
- initial[col.name] = false
- } else {
- initial[col.name] = ''
- }
- })
- return initial
-}
-
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record
@@ -67,20 +50,14 @@ function cleanRowData(
return cleanData
}
-function getInitialRowData(
- mode: RowModalProps['mode'],
- columns: ColumnDefinition[],
- row?: TableRow
-): Record {
- if (mode === 'add' && columns.length > 0) {
- return createInitialRowData(columns)
- }
- if (mode === 'edit' && row) {
- return row.data
- }
- return {}
-}
-
+/**
+ * Modal for editing a row's values or confirming row deletion.
+ *
+ * `rowData` is initialized from the `row` prop at mount time only. Both call-sites
+ * conditionally mount this component per open, so each open gets fresh state. If a
+ * call-site ever keeps it mounted across target-row changes, it must supply a `key`
+ * prop (e.g. the row id) so React remounts with the new row's values.
+ */
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -90,18 +67,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const columns = schema?.columns || []
const [rowData, setRowData] = useState>(() =>
- getInitialRowData(mode, columns, row)
+ mode === 'edit' && row ? row.data : {}
)
const [error, setError] = useState(null)
- const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
const isSubmitting =
- createRowMutation.isPending ||
- updateRowMutation.isPending ||
- deleteRowMutation.isPending ||
- deleteRowsMutation.isPending
+ updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending
const handleFormSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
@@ -110,16 +83,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
try {
const cleanData = cleanRowData(columns, rowData)
- if (mode === 'add') {
- await createRowMutation.mutateAsync({ data: cleanData })
- } else if (mode === 'edit' && row) {
+ if (row) {
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
}
onSuccess()
} catch (err) {
- logger.error(`Failed to ${mode} row:`, err)
- setError(getErrorMessage(err, `Failed to ${mode} row`))
+ logger.error('Failed to edit row:', err)
+ setError(getErrorMessage(err, 'Failed to edit row'))
}
}
@@ -143,7 +114,6 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
}
const handleClose = () => {
- setRowData({})
setError(null)
onClose()
}
@@ -160,11 +130,6 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
title={`Delete ${isSingleRow ? 'Row' : `${deleteCount} Rows`}`}
description={
<>
- {error && (
-
- {error}
-
- )}
Are you sure you want to delete {isSingleRow ? 'this row' : `these ${deleteCount} rows`}
?{' '}
@@ -179,32 +144,21 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
pending: isSubmitting,
pendingLabel: 'Deleting...',
}}
- />
+ >
+ {error}
+
)
}
- const isAddMode = mode === 'add'
-
return (
-
- handleClose()}>
-
-
{isAddMode ? 'Add New Row' : 'Edit Row'}
-
- {isAddMode ? 'Fill in the values for' : 'Update values for'} {table?.name ?? 'table'}
-
-
-
-
-