Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .claude/rules/sim-ui-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
paths:
- "apps/sim/**/*.tsx"
- "apps/sim/components/emcn/**"
---

# UI Copy

**Do not add subtitles, helper text, or descriptive copy beneath headings, labels, cards, or settings by default.** Prefer one concise, self-explanatory heading or label. Only add supporting copy when the user explicitly asks for it, or when it is necessary to prevent misunderstanding or error — and never use it to restate the heading.

This applies to product surfaces: settings rows, modals, panels, cards, list rows, empty states, form fields, and section headers. Marketing surfaces (`app/(landing)`, docs) are governed by `constitution.md` instead.

**Carve-out — settings section metadata.** `SettingsNavigationItem.description` in `components/settings/navigation.ts` stays required, and `SettingsPanel` keeps rendering it as the page subtitle. Settings sections are reached through a nav list where the description is the only thing distinguishing adjacent sections, so it earns its place by the "prevents misunderstanding" test. Keep those descriptions verb-first and one line, per `sim-settings-pages.md`. Everything else on a settings page — inline `<p>` blurbs under section headings, field hints, modal bodies, row subtitles — follows the default rule above.

## The default is no description

```tsx
// ✗ Bad — the subtitle restates the heading
<h3>API Keys</h3>
<p className='text-[var(--text-muted)] text-caption'>Manage your API keys.</p>

// ✗ Bad — decorative filler under a field label
<ChipModalField title='Workspace name' hint='The name of your workspace.' />

// ✓ Good — the label carries the whole meaning
<h3>API Keys</h3>
<ChipModalField title='Workspace name' />
```

If a heading needs a subtitle to be understood, the heading is wrong. Fix the heading — don't append a second line.

## When supporting copy earns its place

Keep (or add) a description only when it carries information the label cannot, and its absence would cause a mistake:

- **Irreversible or destructive consequences** — "Deleting this workspace removes every workflow and log. This cannot be undone."
- **A non-obvious format, unit, or bound** — "Comma-separated. Max 50 domains.", "Cost per 1M input tokens."
- **A security or access implication** — "This key is shown once and grants full workspace access."
- **A state the user cannot otherwise see** — "Inherited from your organization's policy."
- **Instructional copy that advances a flow** — "We sent a 6-digit code to you@example.com."

Everything else — restatements, "Manage your X", "Configure your Y", feature blurbs, encouragement — gets deleted.

## Component APIs

Description/hint slots on shared components are **optional**, never required, and must reserve no layout space when omitted. A component that forces every consumer to supply a subtitle forces every consumer to violate this rule. When adding a new shared component, ship it without a description slot and add one only once a real caller meets the bar above.
44 changes: 44 additions & 0 deletions .cursor/rules/sim-ui-copy.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
description: UI copy conventions — no default subtitles or helper text under headings, labels, cards, or settings
globs: ["apps/sim/**/*.tsx"]
---
# UI Copy

**Do not add subtitles, helper text, or descriptive copy beneath headings, labels, cards, or settings by default.** Prefer one concise, self-explanatory heading or label. Only add supporting copy when the user explicitly asks for it, or when it is necessary to prevent misunderstanding or error — and never use it to restate the heading.

This applies to product surfaces: settings rows, modals, panels, cards, list rows, empty states, form fields, and section headers. Marketing surfaces (`app/(landing)`, docs) are governed by `constitution.mdc` instead.

**Carve-out — settings section metadata.** `SettingsNavigationItem.description` in `components/settings/navigation.ts` stays required, and `SettingsPanel` keeps rendering it as the page subtitle. Settings sections are reached through a nav list where the description is the only thing distinguishing adjacent sections. Everything else on a settings page — inline `<p>` blurbs under section headings, field hints, modal bodies, row subtitles — follows the default rule above.

## The default is no description

```tsx
// ✗ Bad — the subtitle restates the heading
<h3>API Keys</h3>
<p className='text-[var(--text-muted)] text-caption'>Manage your API keys.</p>

// ✗ Bad — decorative filler under a field label
<ChipModalField title='Workspace name' hint='The name of your workspace.' />

// ✓ Good — the label carries the whole meaning
<h3>API Keys</h3>
<ChipModalField title='Workspace name' />
```

If a heading needs a subtitle to be understood, the heading is wrong. Fix the heading — don't append a second line.

## When supporting copy earns its place

Keep (or add) a description only when it carries information the label cannot, and its absence would cause a mistake:

- **Irreversible or destructive consequences** — "Deleting this workspace removes every workflow and log. This cannot be undone."
- **A non-obvious format, unit, or bound** — "Comma-separated. Max 50 domains.", "Cost per 1M input tokens."
- **A security or access implication** — "This key is shown once and grants full workspace access."
- **A state the user cannot otherwise see** — "Inherited from your organization's policy."
- **Instructional copy that advances a flow** — "We sent a 6-digit code to you@example.com."

Everything else — restatements, "Manage your X", "Configure your Y", feature blurbs, encouragement — gets deleted.

## Component APIs

Description/hint slots on shared components are **optional**, never required, and must reserve no layout space when omitted. A component that forces every consumer to supply a subtitle forces every consumer to violate this rule. When adding a new shared component, ship it without a description slot and add one only once a real caller meets the bar above.
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,7 @@ export function LandingPreviewLogs() {
<thead className='border-[var(--border)] border-b'>
<tr>
{COL_HEADERS.map(({ key, label }) => (
<th
key={key}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<th key={key} className='h-10 px-6 py-1.5 text-left align-middle text-caption'>
<button
type='button'
onClick={() => handleSort(key)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,7 @@ export function LandingPreviewResource({
<thead className='border-[var(--border)] border-b'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<th key={col.id} className='h-10 px-6 py-1.5 text-left align-middle text-caption'>
<button
type='button'
onClick={() => handleSortClick(col.id)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
<th key={col.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-[var(--text-primary)] text-small'>
{col.label}
</span>
<ChevronDown className='ml-auto size-[14px] shrink-0 text-[var(--text-muted)]' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function KnowledgeHeroLoop() {
{COL_HEADERS.map((header) => (
<th
key={header}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-[var(--text-muted)] text-caption'
className='h-10 px-6 py-1.5 text-left align-middle text-[var(--text-muted)] text-caption'
>
{header}
</th>
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/(landing)/logs/components/logs-hero-loop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ export function LogsHeroLoop() {
{COL_HEADERS.map((label) => (
<th
key={label}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-[var(--text-muted)] text-caption'
className='h-10 px-6 py-1.5 text-left align-middle text-[var(--text-muted)] text-caption'
>
{label}
</th>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ function TablesGridPane({ rowCount, filledCount }: TablesGridPaneProps) {
<th key={column.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-[var(--text-primary)] text-small'>
{column.label}
</span>
<ChevronDown className='ml-auto size-[14px] shrink-0 text-[var(--text-muted)]' />
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/app/_styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,15 @@ html.sidebar-booting .sidebar-shell-inner {
letter-spacing: 0.28px;
}

/* Completes Preflight, which resets h1-h6 to `inherit` but leaves `th` at the
UA `bold`. Without this a table header, and every label, input, and button
inside it, silently renders at 700 — so `font-medium` on a header reads as
a step DOWN, and removing it makes the header heavier. Normalized here once
rather than neutralized at each `<th>`. */
th {
font-weight: inherit;
}

/* Ensure visible text caret across inputs and editors */
input,
textarea,
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/app/api/wand/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
'\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.'
}

// Both the JavaScript and Python function-body prompts share this type, so
// the reinforcement stays language-neutral.
if (generationType === 'javascript-function-body') {
finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw function body. Do NOT wrap it in markdown code blocks (no ```javascript, no ```python, no ```). Do NOT include any explanation before or after the code.'
}

if (generationType === 'json-object') {
finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'
Expand Down
40 changes: 40 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-w
import {
listForkExcludedDeployedWorkflows,
loadSourceDeployedStates,
loadTargetWebhookPathsByBlock,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
Expand All @@ -27,6 +28,7 @@ import {
collectForkClearedRefCandidates,
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls'
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'

Expand Down Expand Up @@ -173,6 +175,42 @@ export const GET = withRouteHandler(
})
)

// Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request
// URL again" case, surfaced as an editable pairing before the overwrite instead of discovered
Comment thread
waleedlatif1 marked this conversation as resolved.
// after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the
// promote call, where the same plan is rebuilt and validated against them.
const triggerPlan = buildForkTriggerPlan({
items: plan.items,
sourceStates,
resolveBlockId,
targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds),
})
// The RAW retiring set, not the default resolution: the client derives which of these actually
// stop being served from the picks the user is making right now, so the heads-up and the
// overwrite confirm can never disagree with the Trigger URLs rows.
const retiringTriggerUrls = triggerPlan.retiring.map((row) => ({
workflowName: row.workflowName,
path: row.path,
}))
// Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just
// the decisions, so the section reads as a standing statement of each URL rather than an alert.
//
// A trigger with neither is deliberately absent: whether a block will serve a URL at all is
// only knowable from its webhook row, and a schedule / chat / manual / poller trigger never
// gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative
// flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on
// 345 including `slack_oauth`, which routes by `routingKey` with a NULL path.
const triggerMappings = triggerPlan.slots
.filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0)
.map((slot) => ({
sourceBlockId: slot.sourceBlockId,
blockName: slot.blockName,
workflowName: slot.workflowName,
ownPath: slot.ownPath,
adoptablePaths: slot.adoptablePaths,
defaultAdoptPath: slot.defaultAdoptPath,
}))

const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
sourceId: reference.sourceId,
Expand Down Expand Up @@ -224,6 +262,8 @@ export const GET = withRouteHandler(
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
retiringTriggerUrls,
triggerMappings,
})
}
)
19 changes: 17 additions & 2 deletions apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
const {
otherWorkspaceId,
direction,
dependentValues,
copyResources,
dropReferences,
triggerMappings,
} = parsed.data.body

const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)

Expand All @@ -38,6 +45,8 @@ export const POST = withRouteHandler(
actorName: session.user.name ?? undefined,
dependentValues,
copyResources,
dropReferences,
triggerMappings,
requestId,
})

Expand All @@ -52,6 +61,8 @@ export const POST = withRouteHandler(
blockers: result.blockers,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences,
triggerUrlChanges: result.triggerUrlChanges,
}

if (result.blocked) {
Expand Down Expand Up @@ -91,7 +102,9 @@ export const POST = withRouteHandler(
status:
result.deployFailed > 0 ||
result.needsConfiguration.length > 0 ||
result.clearedOptional.length > 0
result.clearedOptional.length > 0 ||
result.droppedReferences.length > 0 ||
result.triggerUrlChanges.length > 0
? 'completed_with_warnings'
: 'completed',
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
Expand All @@ -110,6 +123,8 @@ export const POST = withRouteHandler(
archivedNames: result.archivedNames,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences.length,
triggerUrlChanges: result.triggerUrlChanges.length,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record sync activity`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1198,10 +1198,12 @@ export function LoadedRichMarkdownEditor({
}}
/>
{showPlaceholder && placeholderHtml && (
// Instant read-only content while the collaborative doc seeds; the editor stays mounted-but-
// hidden below so it renders the seeded doc before the swap. Same layout box → no reflow.
// Instant read-only content while the collaborative doc seeds, swapped for the live editor
// once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base
// text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and
// disables ligatures), so a line wraps identically and never re-wraps on the swap.
<div
className='rich-markdown-prose mx-auto w-full max-w-[48rem] px-8 py-6'
className='ProseMirror rich-markdown-prose mx-auto w-full max-w-[48rem] px-8 py-6'
dangerouslySetInnerHTML={{ __html: placeholderHtml }}
/>
)}
Expand Down
Loading
Loading