Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
465bdbd
fix(settings): keep billing header stable (#7010)
waleedlatif1 Aug 23, 2026
f386769
fix(files): allowlist the schemes a markdown link may target (#7012)
waleedlatif1 Aug 23, 2026
ed335d4
improvement(pricing): list Sandboxes as a Max and Enterprise feature …
icecrasher321 Aug 23, 2026
b44d285
fix(files): download a markdown file as a zip only when it really has…
waleedlatif1 Aug 23, 2026
1cb9c86
improvement(chat): speed up conversation navigation (#7011)
waleedlatif1 Aug 23, 2026
9cecf0b
improvement(provenance): aggregate and attribute unrecorded durable r…
icecrasher321 Aug 23, 2026
a20a546
perf(db): optimize recurring query paths (#7014)
waleedlatif1 Aug 24, 2026
fc7aa66
fix(api): withhold internal failure messages from internal route resp…
waleedlatif1 Aug 24, 2026
49593b3
refactor: replace hand-rolled utilities and dead code with the shared…
waleedlatif1 Aug 24, 2026
cc08749
refactor(utils): add slugify and adopt it at the eight sites that han…
waleedlatif1 Aug 24, 2026
95d08d2
fix(ci): stop the migration safety audit from passing on a branch it …
waleedlatif1 Aug 24, 2026
297e970
refactor: delete code nothing reaches (#7019)
waleedlatif1 Aug 24, 2026
0cbff0e
fix(settings): report a failed settings write instead of reporting su…
waleedlatif1 Aug 24, 2026
7e0d868
fix: surface an unbilled run, and clamp the google-docs page cap (#7025)
waleedlatif1 Aug 24, 2026
cc16d23
fix(react-query): close the lint's blind spots, and the drift they hi…
waleedlatif1 Aug 24, 2026
f37c24e
fix(ci): walk every route entry the workspace app composes, not just …
waleedlatif1 Aug 24, 2026
1aa714c
fix(ci): stop failing the API audit for adding a compliant route (#7027)
waleedlatif1 Aug 24, 2026
82b02fb
improvement(ui): standardize modal default actions (#7029)
waleedlatif1 Aug 24, 2026
67fc2ae
fix(ci): require a default export before treating a file as a route e…
waleedlatif1 Aug 24, 2026
528b34f
fix(workflow): hide idle nested subflow end handles (#6976)
BillLeoutsakosvl346 Aug 24, 2026
efe8a14
fix(ci): give push builds a base their audits can actually read (#7033)
waleedlatif1 Aug 24, 2026
fbeea53
fix(files): normalize encoded embedded ids (#7035)
waleedlatif1 Aug 24, 2026
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
10 changes: 10 additions & 0 deletions .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
- **`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.

## Modal keyboard defaults

Declare keyboard intent on the action-owning primitive; never add document-level or per-callsite Enter listeners.

- `ChipModalFooter` defaults to `defaultAction='primary'`. A plain Enter in a canonical single-line field or a custom plain input invokes the enabled primary action. Use `'none'` when submission must require an explicit click, such as an irreversible destructive action or an editor whose nested control owns Enter. Use `'dismiss'` only when dismissal is genuinely the modal's default decision.
- `ChipConfirmModal` fails safe with `defaultAction='dismiss'`. Opt into `'confirm'` only for an audited, low-impact reversible or non-destructive decision. Deleting an aggregate resource such as a workflow, table, knowledge base, or folder remains `'dismiss'` even when it can be restored, because the action takes a broad dependent graph offline. Use `'none'` for typed confirmations and severe account, ownership, or access changes. Button color never determines keyboard behavior.
- Textareas, native forms, buttons, links, comboboxes, menus, listboxes, tag/email inputs, IME composition, modified Enter, and disabled or pending actions retain their native behavior. A native form remains the sole submission path so browser validation is not bypassed.
- A custom field containing a search, token editor, or another input that owns Enter must set `submitOnEnter={false}` on `ChipModalField`. Do not attach a duplicate `onKeyDown` handler merely to call the footer action.
- Initial focus goes to the first visible editable text control. With no text control, the declared real button receives focus; `'none'` focuses the dialog surface. A safe dismiss default never turns Enter in a text field into data loss—the field simply does not publish a submit action.

## Authoring principles

- **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string.
Expand Down
13 changes: 13 additions & 0 deletions .claude/rules/sim-react-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])

Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).

## Prefetch dynamic destination lists on intent

For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate
`<Link prefetch={true}>` behind deliberate hover or keyboard focus, and prefetch destination
server state with the consumer's shared React Query options. A short, cancelable hover dwell
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
let the actual unmodified click start the data request.

If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
mounted until its peer is ready, the intent path must warm both the full route and its critical
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.

## Local feature barrels are the convention — do not "fix" them

Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them.
8 changes: 6 additions & 2 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
by a **title + description from navigation metadata**. The chrome stays mounted
across section navigation (it never re-renders or re-lays-out). Each section
renders through the **`SettingsPanel`** registrar
across section navigation. Its routed title and description are available before
the section body resolves. Each section renders through the **`SettingsPanel`**
registrar
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
the shell its header data and renders only the section body. Sections supply
**data**, never chrome.
Expand Down Expand Up @@ -82,6 +83,9 @@ return (
`children` instead and omit the prop.
- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a
detail sub-view that needs a different heading; normal pages never pass these.
A top-level page's header identity must remain stable while its data loads:
never replace navigation metadata with client-fetched copy after first paint.
Put data-dependent context in the page body instead.
- `scrollContainerRef?: React.Ref<HTMLDivElement>` — forwards a ref to the scroll
region (e.g. programmatic scroll-to-bottom).

Expand Down
5 changes: 5 additions & 0 deletions .claude/rules/sim-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l

Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.

The narrow exception is a continuity-focused peer switch that deliberately keeps the current
view mounted and follows the full-route plus critical-data intent-prefetch rule in
`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the
route-level `loading.tsx` that would replace the current peer before the destination is ready.

This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".

## Debounced text inputs
Expand Down
54 changes: 41 additions & 13 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,18 @@ jobs:
timeout-minutes: 15

steps:
# The diff-based audits below need a base commit to read, and the default
# depth of 1 clones a single commit with no parent. They normally fetch
# their base by SHA (see "Resolve base ref"), so this depth only covers the
# `HEAD~1` fallback — but without it that fallback resolves to nothing.
#
# Worth stating because the failure was invisible for so long: the migration
# audit read the resulting `git diff` failure as "no migrations changed" and
# exited 0, so it had never actually run on a push build.
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 2

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
Expand Down Expand Up @@ -104,15 +114,40 @@ jobs:

echo "✅ All env flags are properly configured"

- name: Check block registry invariants
# One fetch for both base-ref audits, and no `|| true`: a swallowed fetch leaves
# the base ref absent, which neither audit can tell apart from a branch that
# changed nothing. The block-registry check at least degrades to a visible
# `⚠ … skipping` line; the migration audit printed `✓ No new migrations to
# check` and exited 0, clearing the only guard on production DDL.
#
# Depth stays at 1 — without a merge-base the migration audit diffs the two
# tips, which under `--diff-filter=AM` is exactly the migrations new here.
# Resolved once for both diff-based audits, and never with `|| true`: a
# swallowed fetch leaves the base absent, which neither audit can tell apart
# from a branch that changed nothing.
#
# On push the base is `github.event.before`, the tip the branch had before
# this push — not `HEAD~1`, which names only the last commit and would let a
# multi-commit push slip every earlier commit's migrations past the audit.
# It is fetched by SHA at depth 1; the audits diff two tips and need no
# common ancestry. An all-zero `before` means the branch is new and has no
# predecessor to diff, so `HEAD~1` remains the fallback there.
- name: Resolve base ref for diff-based audits
id: audit_base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="origin/${{ github.base_ref }}"
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
git fetch --depth=1 origin "${{ github.base_ref }}"
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
elif [ -n "${{ github.event.before }}" ] &&
[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
git fetch --depth=1 origin "${{ github.event.before }}"
echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
else
BASE_REF="HEAD~1"
echo "ref=HEAD~1" >> "$GITHUB_OUTPUT"
fi
bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF"

- name: Check block registry invariants
run: bun run apps/sim/scripts/check-block-registry.ts "${{ steps.audit_base.outputs.ref }}"

- name: Lint code
run: bun run lint:check
Expand All @@ -127,14 +162,7 @@ jobs:
run: bun run docs-manifest:check

- name: Migration safety (zero-downtime) audit
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="origin/${{ github.base_ref }}"
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
else
BASE_REF="HEAD~1"
fi
bun run check:migrations "$BASE_REF"
run: bun run check:migrations "${{ steps.audit_base.outputs.ref }}"

# Every workspace, not just realtime. packages/emcn, packages/utils,
# apps/desktop and apps/docs had no type check in CI at all; apps/sim's
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/workflow-preview/docs-container-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface DocsContainerData {
name: string
blockType: string
size?: { width: number; height: number }
parentId?: string
}

/**
Expand All @@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({
name: data.name,
width: data.size?.width,
height: data.size?.height,
parentId: data.parentId,
isPreview: true,
}

Expand Down
63 changes: 63 additions & 0 deletions apps/docs/components/workflow-preview/workflow-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { BLOCK_Z_BASE, CONTAINER_CHILD_Z_BASE, getEdgeZIndex } from '@sim/workflow-renderer'
import { describe, expect, it } from 'vitest'
import { type PreviewBlock, type PreviewWorkflow, toReactFlowElements } from './workflow-data'

const block = (
overrides: Partial<PreviewBlock> & Pick<PreviewBlock, 'id' | 'type'>
): PreviewBlock => ({
name: overrides.id,
bgColor: '#000000',
rows: [],
position: { x: 0, y: 0 },
...overrides,
})

const workflow: PreviewWorkflow = {
id: 'nested-subflows',
name: 'Nested subflows',
blocks: [
block({ id: 'start', type: 'starter' }),
block({ id: 'loop', type: 'loop', size: { width: 500, height: 300 } }),
block({
id: 'parallel',
type: 'parallel',
parentId: 'loop',
position: { x: 24, y: 64 },
size: { width: 400, height: 200 },
}),
block({ id: 'agent', type: 'agent', parentId: 'loop', position: { x: 24, y: 140 } }),
],
edges: [
{ id: 'start-loop', source: 'start', target: 'loop' },
{ id: 'loop-parallel', source: 'loop', target: 'parallel' },
{ id: 'loop-agent', source: 'loop', target: 'agent' },
],
}

describe('toReactFlowElements layering', () => {
it('places incoming edges on their container target layer', () => {
const { nodes, edges } = toReactFlowElements(workflow, false, {
highlightEdge: 'loop-parallel',
})
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))

expect(nodeById.get('loop')?.zIndex).toBe(0)
expect(nodeById.get('parallel')?.zIndex).toBe(1)
expect(edgeById.get('start-loop')?.zIndex).toBe(0)
expect(edgeById.get('loop-parallel')?.zIndex).toBe(1)
})

it('keeps ordinary cards above normally layered edges', () => {
const { nodes, edges } = toReactFlowElements(workflow)
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const edgeById = new Map(edges.map((edge) => [edge.id, edge]))

expect(nodeById.get('start')?.zIndex).toBe(BLOCK_Z_BASE)
expect(nodeById.get('agent')?.zIndex).toBe(CONTAINER_CHILD_Z_BASE)
expect(edgeById.get('loop-agent')?.zIndex).toBe(getEdgeZIndex(0))
})
})
37 changes: 36 additions & 1 deletion apps/docs/components/workflow-preview/workflow-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
BLOCK_Z_BASE,
CONTAINER_CHILD_Z_BASE,
getEdgeZIndex,
getEdgeZIndexForTarget,
} from '@sim/workflow-renderer'
import { type Edge, type Node, Position } from 'reactflow'

/**
Expand Down Expand Up @@ -61,6 +67,24 @@ export interface HighlightOptions {
selectedBlock?: string
}

/** Semantic container depth used for z-order while docs positions stay flattened. */
function getNestingDepth(block: PreviewBlock, blocksById: Map<string, PreviewBlock>): number {
let depth = 0
let parentId = block.parentId
const visited = new Set<string>()

while (parentId && !visited.has(parentId)) {
const parent = blocksById.get(parentId)
if (!parent) break

visited.add(parentId)
depth += 1
parentId = parent.parentId
}

return depth
}

/**
* Converts a {@link PreviewWorkflow} to React Flow nodes and edges.
*
Expand All @@ -81,6 +105,7 @@ export function toReactFlowElements(

const nodes: Node[] = workflow.blocks.map((block, index) => {
const isContainer = Boolean(block.size)
const nestingDepth = getNestingDepth(block, blocksById)
// Nested blocks are authored relative to their container; render them at
// absolute coordinates (not React Flow parentNode children) so the edges
// between a container and its nested blocks render reliably and on top.
Expand All @@ -92,7 +117,7 @@ export function toReactFlowElements(
id: block.id,
type: isContainer ? 'previewContainer' : 'previewBlock',
position,
zIndex: isContainer ? 0 : 1,
zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE,
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
data: {
name: block.name,
Expand All @@ -103,6 +128,7 @@ export function toReactFlowElements(
tools: block.tools,
hideTargetHandle: block.hideTargetHandle,
size: block.size,
parentId: block.parentId,
index,
animate,
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
Expand All @@ -127,6 +153,14 @@ export function toReactFlowElements(
// so edges into and out of Loop/Parallel containers still connect.
const sourceBlock = blocksById.get(e.source)
const targetBlock = blocksById.get(e.target)
const parentContainer = blocksById.get(sourceBlock?.parentId ?? targetBlock?.parentId ?? '')
const baseZIndex = getEdgeZIndex(
parentContainer ? getNestingDepth(parentContainer, blocksById) : undefined,
{ isHighlighted: isEdgeHighlight }
)
const targetContainerZIndex = targetBlock?.size
? getNestingDepth(targetBlock, blocksById)
: undefined
const sourceHandle =
e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source')
const targetHandle = targetBlock?.size ? undefined : 'target'
Expand All @@ -142,6 +176,7 @@ export function toReactFlowElements(
},
sourceHandle,
targetHandle,
zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex),
data: {
animate,
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
"test": "vitest run",
"type-check": "fumadocs-mdx && tsc --noEmit",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
Expand Down Expand Up @@ -47,6 +48,7 @@
"@types/react-dom": "^19.0.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.12",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
3 changes: 0 additions & 3 deletions apps/sim/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,9 +464,6 @@ export default function LoginPage({
title='Email'
value={forgotPasswordEmail}
onChange={(value) => setForgotPasswordEmail(value)}
onSubmit={() => {
if (!isSubmittingReset) void handleForgotPassword()
}}
required
placeholder='you@example.com'
/>
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ const FALLBACK_STATUS: ProviderStatus = {
const SOCIAL_BTN =
'relative flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--border-1)] text-[13.5px] text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50'

/** Auth providers are peer choices, so opening the dialog must not arm one or dismissal. */
function focusAuthDialog(event: Event): void {
event.preventDefault()
const content = event.currentTarget as HTMLElement | null
content?.focus()
}

function fetchProviderStatus(): Promise<ProviderStatus> {
if (fetchPromise) return fetchPromise
fetchPromise = requestJson(getAuthProvidersContract, {})
Expand Down Expand Up @@ -155,7 +162,11 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent size='sm' className='dark bg-[var(--bg)] text-[var(--text-primary)]'>
<ModalContent
size='sm'
className='dark bg-[var(--bg)] text-[var(--text-primary)]'
onOpenAutoFocus={focusAuthDialog}
>
<ModalTitle className='sr-only'>
{effectiveView === 'login' ? 'Log in' : 'Create account'}
</ModalTitle>
Expand Down
9 changes: 1 addition & 8 deletions apps/sim/app/(landing)/models/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ComponentType } from 'react'
import { slugify } from '@sim/utils/string'
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'

const PROVIDER_PREFIXES: Record<string, string[]> = {
Expand Down Expand Up @@ -224,14 +225,6 @@ function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}

function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/--+/g, '-')
}

function getProviderPrefixes(providerId: string): string[] {
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
}
Expand Down
Loading
Loading