Skip to content

Commit ea3face

Browse files
committed
Merge remote-tracking branch 'origin/staging' into HEAD
# Conflicts: # .agents/skills/react-query-best-practices/SKILL.md
2 parents 60185fc + ab35ff4 commit ea3face

76 files changed

Lines changed: 2151 additions & 743 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/react-query-best-practices/SKILL.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ Read these before analyzing:
3737
- Use `enabled` to prevent queries from running without required params
3838
- Warm data for hover/focus intent with `queryClient.prefetchQuery` and shared `queryOptions`; never temporarily enable a mounted hidden observer, which can remain active after focus restoration and refetch data for closed UI
3939
- When gating a query by view or modal state, move every consumer to the active query too: imperative refresh/pagination, loading and error feedback, and data-derived controls must never read a disabled query or placeholder data from a previous key
40+
- Compose caller-controlled `enabled` options with required-param guards (`Boolean(id) && (options?.enabled ?? true)`). Never spread options after an internal guard, because `{ enabled: true }` can silently re-enable an invalid request.
41+
- A disabled query can still report `isPending: true`. Aggregate loading state only for queries that are applicable/enabled, or an optional query can hold the whole surface in a permanent loading state.
42+
- Deferred authorization or policy queries must fail closed. Do not give pending/error data the same fallback as a successfully loaded unrestricted policy; disable guarded actions until the policy query succeeds.
43+
- Server prefetches must call the authorized use case, apply the route presenter/response schema, and reuse the client's exact key, mapper, and stale time. Keep all fallible auth/read/parse work inside `queryFn` so an optional warm cannot fail the page, and never bypass a route that redacts fields.
4044

4145
### Mutations
4246
- Use `onSettled` (not `onSuccess`) for cache reconciliation — it fires on both success and error
@@ -48,7 +52,7 @@ Read these before analyzing:
4852
- Never copy query data into useState. Use query data directly in components.
4953
- Never copy query data into Zustand stores (exception: mutation callbacks that coordinate cross-store state like temp ID replacement)
5054
- The query cache is not a local state manager — `setQueryData` is for optimistic updates only
51-
- Forms are the one deliberate exception: copy server data into local form state with `staleTime: Infinity`
55+
- Forms are the one deliberate exception: once query data exists, initialize a keyed form subtree from it with lazy state initializers. Do not synchronize query data into draft state with an Effect; key the form by resource identity so switching resources resets every draft/modal/upload field together. Keep independent queries in the outer wrapper so they still start in parallel.
5256

5357
## Steps
5458

.agents/skills/you-might-not-need-an-effect/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ Steps:
1616
1. Read https://react.dev/learn/you-might-not-need-an-effect to understand the guidelines
1717
2. Analyze the specified scope for useEffect anti-patterns
1818
3. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
19+
20+
## Query-backed forms
21+
22+
When query data supplies the initial values for an editable form, do not copy it into draft state in an Effect. Render loading chrome in an outer component, then mount a keyed form child once data exists and initialize its state lazily from props. Key by the resource identity so every related draft, dialog, and upload state resets together when the resource changes. Keep independent queries in the outer component to preserve parallel fetching.

.claude/rules/sim-list-ordering.md

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,38 +26,48 @@ Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export
2626

2727
Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform.
2828

29-
## Grouping: one rule, against the consequential group
29+
## Grouping: a rule marks a change in what the action acts on
3030

31-
Order is governed above. **Separators are governed here** — and the answer is: use at most one.
31+
Order is governed above. **Separators are governed here.**
3232

33-
Put a single `DropdownMenuSeparator` against the **consequential group** — the actions that
34-
delete, detach, or change a run — and nowhere else. Everything on the other side of it runs
35-
uninterrupted in toolbar-mirroring order.
33+
A `DropdownMenuSeparator` earns its place when the next group stops acting on the thing the user
34+
clicked. That is the whole test — one question, asked the same way in every menu:
3635

37-
That group trails in almost every menu, so in practice the rule reads "one rule immediately
38-
before Delete / Leave / Close / Hide". It leads in exactly one place: the **logs row menu**,
39-
where `Retry` and `Cancel Run` are the primary actions on a failed run and sit at the top, with
40-
the rule beneath them. Ordering follows the surface (see "The rule" above); the separator simply
41-
fences whichever end the consequential group occupies. A menu whose consequential actions are
42-
merely *disabled* still gets no extra rule — `disabled` is not a group.
36+
| The group | Gets a rule before it |
37+
| --- | --- |
38+
| Acts on the clicked item (open, rename, duplicate, export, copy, edit, pin, run) | no — this is the body of the menu |
39+
| Acts on **something else** — the page's filters or view, or a newly created sibling | yes |
40+
| **Destroys or detaches** it (delete, leave, close, hide, remove) | yes |
41+
42+
Most row menus only ever have the one transition, so they carry one rule, immediately before
43+
`Delete`. A menu that also filters the page or inserts siblings carries two. Nothing carries
44+
more, because there is no third thing a menu acts on.
45+
46+
Do **not** band by verb. "Navigation", "status", "edit", "copy" are categories of *what the verb
47+
is*, not of *what it touches*, and the user meets no such taxonomy anywhere else — every toolbar
48+
in the app is a flat `gap-1` chip row with no dividers. Menus banded that way put the same action
49+
in different groups depending on which siblings happened to be visible.
50+
51+
The consequential group trails in almost every menu. It leads in exactly one: the **logs row
52+
menu**, where `Retry` and `Cancel Run` act on the run itself and are the primary actions on a
53+
failure, so they sit on top with the rule beneath them. Ordering follows the surface (see "The
54+
rule" above); the separator fences whichever end that group occupies.
55+
56+
A group whose items are merely *disabled* still gets no extra rule — `disabled` is not a group.
4357

4458
```tsx
4559
// ✗ Bad — four semantic bands the user meets nowhere else
4660
Open in new tab │─── Rename, Lock │─── Duplicate, Export │─── Delete
4761

48-
// ✓ Good — one rule, isolating the irreversible action
62+
// ✓ Good — one rule, where the menu stops acting on the workflow
4963
Open in new tab, Rename, Lock, Duplicate, Export │─── Delete
5064
```
5165

52-
**Why one.** No toolbar in this app renders a divider — every header is a flat
53-
`HEADER_ACTION_CLUSTER` (`gap-1`) chip row and every bulk action bar a flat `gap-[5px]` run. A
54-
menu banded into navigation / status / edit / copy / destructive therefore teaches a taxonomy
55-
that appears on no other surface, and because each band is conditional, the same action lands in
56-
a different group depending on which sibling items happen to be visible. The one thing a rule
57-
genuinely buys is a stop before the action you cannot undo.
58-
59-
A second rule is justified only when a menu mixes genuinely different *scopes* — cell-level and
60-
table-level actions in one menu, say — not different verbs.
66+
**Worked examples.** The logs row menu carries two: `Retry, Cancel Run │ Copy Run ID, Copy Link,
67+
Open Workflow, Open Snapshot │ Filter by Workflow, Clear Filters` — the run, then this log, then
68+
the page. The table row and column menus carry two: the rule before `Insert row above` /
69+
`Insert column left` is where the menu stops acting on the clicked cell and starts creating
70+
siblings. Every other row menu in the app has only the destructive transition, so it carries one.
6171

6272
**The one standing exception: menus that emulate a native menu.** The text-editor menu
6373
(`editor-context-menu.tsx`), the terminal menu (`terminal-context-menu.tsx`), and the browser

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ A list orders itself the way the user already reads the same things somewhere el
388388

389389
Encode the order in ONE exported constant and sort by it — never a hand-maintained literal per menu (`RESOURCE_MENU_ORDER` / `byResourceMenuOrder` in `home/components/mothership-view/components/resource-registry`). Render mixed item kinds in a single ordered pass; emitting all submenu-backed families and then all flat ones silently pins every submenu to the top no matter what the constant says. Divergence is allowed only for search ranking, user-controlled ordering, and recency.
390390

391-
**Grouping**: at most ONE `DropdownMenuSeparator` per menu, fencing the consequential group — immediately before Delete/Leave/Close/Hide in almost every menu, and immediately after Retry/Cancel Run in the logs row menu, where those lead. No toolbar in the app renders a divider, so multi-band menus teach a taxonomy that exists on no other surface. Build each separator's guard from the EXACT render conditions of the items on both sides — a looser guard is what leaves a dangling rule when its group is conditional. Never add a prop to move a rule. Full rule in `.claude/rules/sim-list-ordering.md`.
391+
**Grouping**: a `DropdownMenuSeparator` marks a change in WHAT the action acts on — the clicked item (no rule), something else like the page's filters or a new sibling (rule), or destroying it (rule). Most row menus have only the destructive transition and carry one rule before Delete/Leave/Close/Hide; menus that also filter the page or insert siblings carry two. Never band by verb (navigation/status/edit/copy) — the toolbars are flat, so that taxonomy exists nowhere else. No toolbar in the app renders a divider, so multi-band menus teach a taxonomy that exists on no other surface. Build each separator's guard from the EXACT render conditions of the items on both sides — a looser guard is what leaves a dangling rule when its group is conditional. Never add a prop to move a rule. Full rule in `.claude/rules/sim-list-ordering.md`.
392392

393393
## Styling
394394

apps/sim/app/api/audit-logs/export/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
queryAuditLogs,
1111
} from '@/lib/audit-logs/query'
1212
import { getSession } from '@/lib/auth'
13+
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
1314
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14-
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
1515
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
1616
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
1717

apps/sim/app/api/logs/export/route.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { getSession } from '@/lib/auth'
88
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
9-
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
9+
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
1212
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -17,15 +17,6 @@ const logger = createLogger('LogsExportAPI')
1717

1818
export const revalidate = 0
1919

20-
function escapeCsv(value: any): string {
21-
if (value === null || value === undefined) return ''
22-
const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value)
23-
if (/[",\n]/.test(str)) {
24-
return `"${str.replace(/"/g, '""')}"`
25-
}
26-
return str
27-
}
28-
2920
export const GET = withRouteHandler(async (request: NextRequest) => {
3021
try {
3122
const session = await getSession()
@@ -61,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6152
? and(workspaceCondition, filterConditions)
6253
: workspaceCondition
6354

64-
const header = [
55+
const header = toCsvRow([
6556
'startedAt',
6657
'level',
6758
'workflow',
@@ -72,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7263
'executionId',
7364
'message',
7465
'traceSpans',
75-
].join(',')
66+
])
7667

7768
const access = await checkWorkspaceAccess(params.workspaceId, userId)
7869
if (!access.hasAccess) {
@@ -147,18 +138,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
147138
error: getErrorMessage(rowError),
148139
})
149140
}
150-
const line = [
151-
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
152-
escapeCsv(r.level),
153-
escapeCsv(r.workflowName),
154-
escapeCsv(r.trigger),
155-
escapeCsv(r.totalDurationMs ?? ''),
156-
escapeCsv(r.costTotal ?? ''),
157-
escapeCsv(r.workflowId ?? ''),
158-
escapeCsv(r.executionId ?? ''),
159-
escapeCsv(message),
160-
escapeCsv(tracesJson),
161-
].join(',')
141+
const line = toCsvRow([
142+
formatCsvValue(r.startedAt?.toISOString?.() || r.startedAt),
143+
formatCsvValue(r.level),
144+
formatCsvValue(r.workflowName),
145+
formatCsvValue(r.trigger),
146+
formatCsvValue(r.totalDurationMs ?? ''),
147+
formatCsvValue(r.costTotal ?? ''),
148+
formatCsvValue(r.workflowId ?? ''),
149+
formatCsvValue(r.executionId ?? ''),
150+
formatCsvValue(message),
151+
formatCsvValue(tracesJson),
152+
])
162153
controller.enqueue(encoder.encode(`${line}\n`))
163154
}
164155

apps/sim/app/api/table/[tableId]/export/route.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { captureServerEvent } from '@/lib/posthog/server'
9-
import {
10-
createTableExportStream,
11-
exportContentType,
12-
sanitizeExportFilename,
13-
} from '@/lib/table/export-stream'
9+
import { sanitizeExportFilename } from '@/lib/table/export-format'
10+
import { createTableExportStream, exportContentType } from '@/lib/table/export-stream'
1411
import { accessError, checkAccess } from '@/app/api/table/utils'
1512

1613
interface RouteParams {

apps/sim/app/api/users/me/usage-logs/export/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
toBillingUsageLogSource,
1010
toInternalUsageLogSources,
1111
} from '@/lib/billing/usage-sources'
12+
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13-
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
1414
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
1515

1616
const logger = createLogger('UsageLogsExportAPI')

apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
1515
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options'
1616
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders/move-options'
17+
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
1718

1819
interface FolderContextMenuProps {
1920
isOpen: boolean
@@ -29,6 +30,7 @@ interface FolderContextMenuProps {
2930
pinned: boolean
3031
moveOptions?: MoveOptionNode[]
3132
canEdit: boolean
33+
selectedCount: number
3234
}
3335

3436
/**
@@ -56,8 +58,12 @@ export const FolderContextMenu = memo(function FolderContextMenu({
5658
pinned,
5759
moveOptions,
5860
canEdit,
61+
selectedCount,
5962
}: FolderContextMenuProps) {
63+
const isMultiSelect = selectedCount > 1
6064
const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0)
65+
const hasActionsAboveDestructive = !isMultiSelect || hasMove
66+
const hasAvailableActions = !isMultiSelect || canEdit
6167

6268
return (
6369
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -75,42 +81,54 @@ export const FolderContextMenu = memo(function FolderContextMenu({
7581
sideOffset={4}
7682
onCloseAutoFocus={(e) => e.preventDefault()}
7783
>
78-
<DropdownMenuItem onSelect={onOpen}>
79-
<Eye />
80-
Open
81-
</DropdownMenuItem>
82-
<DropdownMenuItem onSelect={onTogglePin}>
83-
<Pin />
84-
{pinned ? 'Unpin' : 'Pin'}
85-
</DropdownMenuItem>
86-
{onCopyId && (
87-
<DropdownMenuItem onSelect={onCopyId}>
88-
<Duplicate />
89-
Copy ID
90-
</DropdownMenuItem>
91-
)}
92-
{canEdit && (
84+
{!hasAvailableActions ? (
85+
<DropdownMenuItem disabled>No actions available</DropdownMenuItem>
86+
) : (
9387
<>
94-
<DropdownMenuItem onSelect={onRename}>
95-
<Pencil />
96-
Rename
97-
</DropdownMenuItem>
98-
{hasMove && (
99-
<DropdownMenuSub>
100-
<DropdownMenuSubTrigger>
101-
<FolderInput />
102-
Move to
103-
</DropdownMenuSubTrigger>
104-
<DropdownMenuSubContent>
105-
{renderMoveOptions(moveOptions!, onMove!)}
106-
</DropdownMenuSubContent>
107-
</DropdownMenuSub>
88+
{!isMultiSelect && (
89+
<>
90+
<DropdownMenuItem onSelect={onOpen}>
91+
<Eye />
92+
Open
93+
</DropdownMenuItem>
94+
<DropdownMenuItem onSelect={onTogglePin}>
95+
<Pin />
96+
{pinned ? 'Unpin' : 'Pin'}
97+
</DropdownMenuItem>
98+
{onCopyId && (
99+
<DropdownMenuItem onSelect={onCopyId}>
100+
<Duplicate />
101+
Copy ID
102+
</DropdownMenuItem>
103+
)}
104+
</>
105+
)}
106+
{canEdit && (
107+
<>
108+
{!isMultiSelect && (
109+
<DropdownMenuItem onSelect={onRename}>
110+
<Pencil />
111+
Rename
112+
</DropdownMenuItem>
113+
)}
114+
{hasMove && (
115+
<DropdownMenuSub>
116+
<DropdownMenuSubTrigger>
117+
<FolderInput />
118+
{selectionActionLabel('Move', selectedCount, 'Move to')}
119+
</DropdownMenuSubTrigger>
120+
<DropdownMenuSubContent>
121+
{renderMoveOptions(moveOptions!, onMove!)}
122+
</DropdownMenuSubContent>
123+
</DropdownMenuSub>
124+
)}
125+
{hasActionsAboveDestructive && <DropdownMenuSeparator />}
126+
<DropdownMenuItem onSelect={onDelete}>
127+
<Trash />
128+
{selectionActionLabel('Delete', selectedCount)}
129+
</DropdownMenuItem>
130+
</>
108131
)}
109-
<DropdownMenuSeparator />
110-
<DropdownMenuItem onSelect={onDelete}>
111-
<Trash />
112-
Delete
113-
</DropdownMenuItem>
114132
</>
115133
)}
116134
</DropdownMenuContent>

0 commit comments

Comments
 (0)