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.
43 changes: 43 additions & 0 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,49 @@ describe('AgentBlockHandler', () => {
)
})

/**
* A stalled model call reaches here as the runtime's own `TimeoutError`, whose bare
* message ("The operation timed out.") names nothing. It must become a Sim-level
* message WITHOUT discarding the phase detail the provider attached — that detail is
* the only thing distinguishing "never answered" from "body never completed".
*/
it('maps a provider TimeoutError to a Sim message while keeping the phase detail', async () => {
const inputs = { model: 'gpt-4o', userPrompt: 'hi', apiKey: 'test-api-key' }
mockGetProviderFromModel.mockReturnValue('openai')

// Faithful to production: providers rewrap the transport failure in a
// ProviderError, which overwrites `name` — so only the cause still classifies it.
const transport = new Error(
'The operation timed out. [phase=reading-response-body elapsedMs=60001 status=200 contentLength=32116]'
)
transport.name = 'TimeoutError'
const wrapped = new Error(transport.message, { cause: transport })
wrapped.name = 'ProviderError'
mockExecuteProviderRequest.mockRejectedValueOnce(wrapped)

const error = await handler.execute(mockContext, mockBlock, inputs).catch((e) => e)

expect(error.message).toContain('Provider request timed out')
expect(error.message).toContain('phase=reading-response-body')
expect(error.message).toContain('status=200')
})

it('maps a provider AbortError the same way', async () => {
const inputs = { model: 'gpt-4o', userPrompt: 'hi', apiKey: 'test-api-key' }
mockGetProviderFromModel.mockReturnValue('openai')

const aborted = new Error('aborted [phase=awaiting-response-headers elapsedMs=12]')
aborted.name = 'AbortError'
const wrapped = new Error(aborted.message, { cause: aborted })
wrapped.name = 'ProviderError'
mockExecuteProviderRequest.mockRejectedValueOnce(wrapped)

const error = await handler.execute(mockContext, mockBlock, inputs).catch((e) => e)

expect(error.message).toContain('Provider request timed out')
expect(error.message).toContain('phase=awaiting-response-headers')
})

it('should handle streaming responses with text/event-stream content type', async () => {
const mockStreamBody = new ReadableStream({
start(controller) {
Expand Down
27 changes: 25 additions & 2 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ import { getToolAsync } from '@/tools/utils.server'

const logger = createLogger('AgentBlockHandler')

/**
* True when a failure originated from a transport deadline or abort, at any depth of the
* cause chain.
*
* Providers rewrap transport failures (`ProviderError` overwrites `name`), so a check on
* the top-level `name` alone misses every wrapped case. Bounded to a short walk so a
* self-referential cause cannot loop.
*/
function isTransportTimeout(error: unknown): boolean {
for (let current = error, depth = 0; current instanceof Error && depth < 5; depth++) {
if (current.name === 'AbortError' || current.name === 'TimeoutError') return true
current = current.cause
}
return false
}

/**
* Handler for Agent blocks that process LLM requests with optional tools.
*/
Expand Down Expand Up @@ -1299,8 +1315,15 @@ export class AgentBlockHandler implements BlockHandler {
timestamp: new Date().toISOString(),
})

if (error.name === 'AbortError') {
throw new Error('Provider request timed out - the API took too long to respond')
/**
* The original message is appended rather than replaced: providers annotate it with
* the request phase they died in, which is the only thing separating a request that
* was never answered from one whose body stalled.
*/
if (isTransportTimeout(error)) {
throw new Error(
`Provider request timed out - the API took too long to respond (${error.message})`
)
}
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error(
Expand Down
Loading
Loading