Commit Graph
5862 Commits
Author SHA1 Message Date
mzxchandra 5718def4fd feat(logs): open the workflow from the log details panel (#6275)
* refactor(logs): centralize workflow-id resolution and editor path

The logs list, its context menu, and the details panel each resolved a
log's workflow id with their own copy of `workflow?.id || workflowId`,
and the list disagreed with the details panel on what counts as a
deleted workflow. Extract `resolveLogWorkflowId` and
`workflowEditorPath` so the three surfaces cannot drift.

`resolveLogWorkflowId` also returns null for Sim agent jobs, which have
no workflow of their own. Only the context menu's "Open Workflow" item
adopts that stricter predicate; cancel and retry keep using the previous
`hasWorkflow` check so their gating is unchanged.

* feat(logs): open the workflow from the log details panel

The workflow name in a log's details panel was static text, so the only
way to reach the workflow was the row's right-click context menu. Make
the label a link to the workflow editor, opening in a new tab so the
log list keeps its filters, scroll position, and open panel.

On hover or keyboard focus the leading workflow icon morphs into
SquareArrowUpRight, reusing the grid-stacked cross-fade already used by
the resource header breadcrumb. Sim agent jobs and deleted workflows
have no reachable workflow and stay static text.

Adds a `group-hover-hover` variant so the morph is gated on a real
hover-capable pointer, matching the existing `hover-hover` variant and
keeping touch devices out of a half-applied hover state.

* refactor(logs): use the existing group-hover variant for the workflow link

Drops the group-hover-hover Tailwind variant this branch added and moves
the details-panel workflow link to plain group-hover:, matching the
variant already used throughout the app. tailwind.config.ts is untouched
by the branch again; all colors, radii, and the focus ring come from
existing design tokens.
2026-08-04 22:27:50 -07:00
Waleed 020ec68cfe fix(sidebar): keep the collapsed profile chip inside the rail (#6282) 2026-08-04 21:55:13 -07:00
Waleed a3a887ad5a fix(docs): point the service-account guides at the real connect flow (#6277)
* fix(docs): point the service-account guides at the real connect flow

Fourteen of the twenty service-account guides sent admins to a workspace
Settings → Integrations tab that does not exist — Integrations is a top-level
workspace route, and there is no integrations section in the settings navigation
at all. The same fourteen then told them to search the catalog for
"<Service> Service Account", a name no catalog entry has: the list is derived
from blocks, so the entries are "Airtable", "Monday", "Wealthbox", and search
matches only name and description.

Both steps now match the six guides that were already correct (Box, Zoho Desk,
Zoom, Salesforce, Pipedrive, Atlassian), so all twenty describe one flow. The
per-guide connect labels were already right and are untouched.

Google needed a third fix: its final step said Click **Save**, but the modal's
primary button is `Add {connectNoun}`, which for Google falls back to
"Add service account". It also has no catalog entry of its own, so the search
now points at Google Drive with a note that any Google integration works.

Also adds `invalidCredentialsHelp` for Wealthbox. Its validator rejects a token
that works only over Wealthbox's documented ACCESS_TOKEN header, because Sim's
tools authenticate with Bearer — a deliberate, documented limitation whose
reason reached the server log and never the user, who saw only "Double-check it".

* fix(docs): make the Wealthbox rejection copy true for every failure path

`invalidCredentialsHelp` replaces the generic message for every
`invalid_credentials` rejection, and the Wealthbox validator raises that code on
three paths: a 402 expired trial, a 401/403 where both header styles fail, and a
401/403 where Bearer fails but the ACCESS_TOKEN probe succeeds. The copy
described only the third, so two of the three told the user their token was
valid and pointed at remediation that could not help.

Now leads with what is checkable in all three cases and makes the Bearer note
conditional on the one signal that distinguishes it — the token working
elsewhere.
2026-08-04 20:07:03 -07:00
Waleed 2c120c35fb improvement(emcn): let a modal refuse every dismissal while an action runs (#6276)
* improvement(emcn): let a modal refuse every dismissal while an action runs

ChipConfirmModal's docs promised "a single dismiss path shared by the header X /
dismiss button / Escape … and disabling dismiss while the confirm is in flight".
Only the dismiss button was ever guarded — Escape, outside-click and the header X
all still closed a confirmation mid-delete. Two knowledge-base connector modals
had the same shape: they guarded onOpenChange against a pending save, then handed
the header X a direct onOpenChange(false) that skipped the guard.

A modal now states the interlock once, as `dismissDisabled` on ChipModal or
ModalContent, and the primitive holds all four exits shut. ModalContent owns the
Radix paths because `{...props}` is spread after its own handlers, so a
consumer-passed onEscapeKeyDown/onInteractOutside would silently drop the
floating-layer guard; it publishes the flag through a context that
ChipModalHeader, ChipModalFooter and ModalHeader read. The two narrow props
compose with `||`, so an explicit `true` still disables a single button and an
explicit `false` cannot punch a hole in the root's guarantee.

Also turns on `turbo run type-check` for every workspace. packages/emcn,
packages/utils, apps/desktop and apps/docs had no type check in CI at all —
only @sim/realtime did — and apps/sim's source was covered solely as a side
effect of `next build`. All 23 workspaces pass today, so it lands green.

* fix(emcn): compose consumer dismiss handlers instead of replacing the guard

ModalContent's own onEscapeKeyDown/onInteractOutside sit before the `{...props}`
spread, so a consumer passing either replaced them — dropping both the
dismissDisabled interlock and the floating-layer guard that keeps a popper
dismissal from closing the modal and freezing the page. The TSDoc argued the
guard had to live here for exactly that reason, then left the same spread able
to defeat it.

Both handlers are now destructured out of props and invoked after the guard, so
the guard always runs and a consumer can still observe or extend the event. No
consumer passes either today, so this was latent rather than live.

* revert(ci): drop the type-check inputs allowlist

The allowlist traded correctness for a modest cache win, in a gate whose only
job is catching type errors. `resolveJsonModule` and `allowJs` are both on, so
.json and .js files participate in type checking and were absent from the list —
`lib/integrations/availability.ts` imports the generated `integrations.json`,
which means regenerating that file would not have invalidated the cache and CI
would have replayed a stale pass over a changed type.

Back to Turbo's default (every non-gitignored file in the package): conservative,
but a type-check gate that can serve a stale success is worse than a slow one.
2026-08-04 19:40:07 -07:00
Siddharth Ganesan 6c10ac25a7 chore(scheduled-tasks): remove scheduled tasks from the vfs (#6274)
* chore(copilot): remove scheduled-task remnants

* chore(copilot): remove task VFS projection
2026-08-04 18:44:56 -07:00
Waleed 0ab44c5b44 improvement(zoho-desk): pick the data center from a dropdown and trim service-account help text (#6271)
* improvement(zoho-desk): pick the data center from a dropdown and trim service-account help text

The Zoho Desk Self Client modal rendered a paragraph of setup steps as the hint
under Client secret, duplicating both the setup guide and two of its own field
hints. Cut it to the one caveat that isn't derivable from the form, and moved it
to the org-identifier field the caveats actually qualify. Data center is now a
dropdown sourced from ZOHO_DESK_DATA_CENTERS.

Same editorial pass across the other service accounts: Zoom, Salesforce,
Shopify, Webflow, Trello and Cal.com dropped setup steps in favor of caveats.

Also adds the documented Zoho Desk params that were missing (list_tickets
assignee/channel/receivedInDays, list_comments and list_threads sortBy,
get_contact and get_thread include), each gated per operation so a stale
subBlock value can't leak into an endpoint that reads the same param name.

* fix(zoho-desk): let an unsupported receivedInDays reach the tool's validation

The block mapper filtered on shape before forwarding, so a fractional or
non-numeric value was dropped and List Tickets then ran with no window at all —
returning the whole queue as though the requested filter had applied. The tool
owns that validation, so the mapper now passes the value straight through.

Adds a block-to-tool seam test: neither side's own tests could catch a value
lost between them.

* fix(zoho-desk): overwrite operation-scoped params instead of omitting them

The block mapper scoped params by destructuring them out of the spread, on the
assumption that a key left out of the return value never reaches the tool. It
does: both call sites merge the mapper's output on top of the original inputs
(`{ ...inputs, ...transformedParams }`), so an omitted key is restored.

The serializer is what actually held this together, and it has a gap — an
advanced subBlock with a retained value is emitted for every operation while the
block's advanced toggle is off, because that branch returns on isNonEmptyValue
without evaluating the subBlock's condition. So a Sort By set on List Tickets
reached List Comments, and a ticket Include reached Get Contact, each rejected
by Zoho. Out-of-range from/limit reached the wire for the same reason.

Every scoped param is now assigned unconditionally, undefined included, so the
merge cannot resurrect a stale value.

Also fixes a crash this branch introduced: clearing the Departments multi-select
stores [], which reached the comma-list normalizer and threw on .split. The
helper now takes arrays, which is what that subBlock actually stores.

The block-to-tool tests now model the real merge rather than the mapper's return
value alone — the previous version passed while production threw on the same
input. Corrects two comments that misstated where Zoho documents customFields
and errorMessage, and splits the shared include subBlock, since Get Ticket
accepts contract and skills and List Tickets does not.

* fix(zoho-desk): do not scope params on the agent-tool path

The previous commit made the mapper assign every operation-scoped param
unconditionally, so the merge could not resurrect a stale value. That is right
on the canvas path and wrong on the agent-tool path, where `operation` is a
sibling of the tool call rather than a member of params: the mapper saw
`operation === undefined`, every gate resolved to undefined, and the merge then
overwrote the model's own arguments with it. A Zoho Desk tool called by an agent
lost every parameter the model supplied.

That path needs no scoping — the tool is already chosen, and the model addresses
tool params by their real names — so it now returns early. Custom fields are
still coerced there, since parsing JSON is a type fix rather than an operation
gate, and that parsing is now shared by both paths.

* fix(zoho-desk): keep the legacy include working on Get Ticket

Splitting the shared `include` subBlock into `include` and `ticketInclude` left
workflows saved before the split reading an empty field, so their Get Ticket
calls silently stopped embedding what they asked for.

Get Ticket now reads `ticketInclude ?? include`. The fallback only goes that
direction: Get Ticket accepts every value List Tickets does plus `contract` and
`skills`, so a legacy value is always valid there, while List Tickets still
reads only `include` and can never receive the two extra tokens it does not
document.
2026-08-04 18:28:50 -07:00
Theodore Li fbd02bc176 feat(admin): add password user creation (#6267)
* feat(admin): add password user creation

* fix(admin): restrict created users to normal role

* improvement(admin): refine add user action

* fix(admin): prevent duplicate user creation

* fix(admin): reflect immediate submission state
2026-08-04 19:48:10 -04:00
Waleed bf5abc9a40 blog(agent-as-yjs-peer): "The Agent Is Just Another Peer" post + live demo (#6268)
* blog(agent-as-yjs-peer): draft post on streaming an agent into a collaborative doc as a Yjs peer

* blog(agent-as-yjs-peer): add cover image

* blog(agent-as-yjs-peer): add live two-peer editing demo component

* improvement(blog): match code blocks and inline code to the in-app editor tokens

* blog(agent-as-yjs-peer): finalize copy, streaming demo, and feature the post

* fix(blog): guard agent demo against missing/never-firing IntersectionObserver

* fix(blog): stream demo bullets and code chip per-character to match the typing cadence

* chore(blog): trim demo comments and apply cleanup (adopt cn, hoist languageMap, chip color)

* blog(agent-as-yjs-peer): swap cover image

* fix(blog): guard agent demo rAF loop against rescheduling after effect cleanup
2026-08-04 16:20:07 -07:00
Waleed b98dd8ba5e fix(emcn): normalize MoreHorizontal and TerminalWindow geometry (#6265)
MoreVertical was migrated to the house geometry; MoreHorizontal was left
on its original 0 0 12 3 fill construction. Because that box is far wider
than it is tall, a square size class scales it by 14/12 and the dots
stretch edge to edge: 3.17px dots across a 14px span, where MoreVertical
draws 1.78px dots across 8.78px. The two are the same glyph rotated 90deg
and sit in the same overflow-menu role, so the mismatch is visible on any
row carrying one.

Redraw MoreHorizontal as the exact transpose of MoreVertical about
(10.25, 9.75). Both now measure 8.80px.

TerminalWindow had the same problem in the resource registry tab strip,
where ten icons render side by side at size-[14px]: it drew 14.00px
filled against neighbours at 10.30-12.05px on a 0.90px stroke. Redraw it
on the 24-box keeping its character - a window, a title bar, three chrome
dots. Now 10.85px.

Three call sites needed updating alongside the viewBox change:

- panel.tsx passed no size and sat in a Button, which does not force-size
  its svg children, so the icon would have jumped to its new 24x24
  intrinsic size inside a 30px button.
- The two sidebar size-[9px] values were compensations for the oversized
  glyph (9px against a 12-wide box happens to yield a ~9px span). Against
  the 24-box they would render 5.64px, so they move to size-[14px], which
  lands at 8.78px - exactly MoreVertical.
2026-08-04 14:37:03 -07:00
Waleed f0e57cb73f fix(emcn/header): icon consistency — breadcrumb menus, Connections, FolderCode (#6263)
* fix(header): give every breadcrumb menu item an icon

The resource crumb's menu (table, knowledge base, document, file) rendered
Rename/Tags/Share/Download/Delete with icons, but the folder crumb's menu
rendered bare labels. The two open from adjacent segments of the same
breadcrumb, so the inconsistency was visible side by side.

Add the matching icons to the three folder-crumb menus.

* fix(emcn): redraw Connections on the house geometry

Connections was a filled glyph in a tight 0 0 13 13 box with a 0.2
hairline stroke. At size-[14px] it rendered a 21.5px glyph at 0.22px
stroke where its neighbours render ~10.5px at 0.90px, so it read as a
different weight class in the resource registry it shares with Library,
Globe and Database.

Redraw as a stroke outline on the 24-box at 1.55, preserving the
topology: a 2x2 grid with a circle top-right, linked along the top,
down the right column, and along the bottom. Now 10.9px at 0.90px.

* fix(emcn): match FolderCode geometry to the folder family

FolderCode carried the same fill-based construction Folder did: a tight
0 0 15 13 box with a 0.3 hairline stroke. At size-[14px] it rendered a
19.9px glyph at 0.28px stroke where a standard icon renders ~10.5px at
0.90px — roughly twice the size at a third the weight.

It renders in Chat's TOOL_ICONS map for glob/mv/mkdir, directly beside
Search, File and Database, so the mismatch was visible in a single list.

Reuse Folder's body path verbatim and inset code brackets centred on the
body, so the whole folder family shares one silhouette and one weight.
2026-08-04 13:41:21 -07:00
Waleed 8ca3b27f43 fix(emcn): match Folder icon geometry to FolderOpen (#6262)
Folder and FolderOpen toggle in place in the sidebar folder rows, but
carried incompatible geometry: Folder was a fill-based outline in a
tight 14.5x13 box at stroke 0.3, while FolderOpen is the house-standard
stroke outline in -1 -2 24 24 at stroke 1.55.

A square size-[16px] therefore scaled the closed folder to ~16x14.3px
where the open one renders ~11.3x10px, so expanding a folder visibly
shrank its icon.

Redraw Folder as FolderOpen's own body outline closed along the
bottom-right, so the pair shares a silhouette, a box and a stroke
weight by construction.
2026-08-04 13:13:56 -07:00
Theodore Li 35fd4ef42f improvement(self-host): simplify capability setup configuration (#6230)
* feat(self-host): add capability-aware setup

* fix(self-host): preserve capability compatibility

* fix(copilot): honor preview availability server-side

* improvement(self-host): centralize capability resolution

* fix(self-host): preserve integration availability paths

* fix(testing): align capability-aware config mocks

* improvement(self-host): simplify capability setup configuration

* fix(setup): preserve unowned storage overrides

* fix(self-host): reconcile storage and allowlists

* fix(integrations): preserve connect deep links
2026-08-04 15:47:36 -04:00
Waleed 39c3fe60d9 improvement(docs): remove Ask AI, add the missing platform surfaces, align the sidebar (#6259)
* improvement(docs): remove Ask AI, add missing surfaces, align sidebar to the app

- Removes the Ask AI widget, its /api/chat route, and the four deps exclusive
  to it (@ai-sdk/openai, @ai-sdk/react, ai, streamdown). lib/embeddings and
  docsEmbeddings stay — /api/search uses them.
- Adds --surface-7 and --surface-hover, the last two platform surfaces docs
  lacked.
- Aligns the sidebar with the app's canonical nav chrome: px-2.5 -> px-2,
  text-small -> text-sm, hover --surface-3 -> --surface-active, and an active
  hover of --surface-6, matching the Chip the app's sidebar items are built on.

* fix(docs): make the sidebar hover CSS agree with the utilities

Review caught that the sidebar hover alignment in this PR had no visual
effect. `global.css` carries !important rules for both the link and button
sidebar items — they exist to beat fumadocs' own styles — and they were still
forcing the pre-alignment values: --surface-3 on an inactive hover, and
--surface-active on an active hover.

So the Tailwind utilities were dead on arrival. The global rules now carry the
app's values instead (--surface-active inactive, --surface-6 active), matching
the utilities rather than fighting them, with a comment noting the two must
move together.
2026-08-04 12:45:10 -07:00
Theodore Li a19ae165e0 fix(landing): restore homepage view tracking (#6260) 2026-08-04 12:45:01 -07:00
Waleed c14818b7bb improvement(emcn): normalize the chevron geometry and consolidate consumers (#6258)
* improvement(emcn): normalize the chevron geometry and consolidate consumers

The sidebar folder arrow read as much larger than the icons beside it. It was
measurably so: `ChevronRight` was a triple outlier in the icon set.

| | ChevronRight | house standard |
|---|---|---|
| viewBox | `0 0 6 10` | 24-based — 149 of 173 icons |
| glyph fill | 80% of the box | 54% median |
| strokeWidth | 1.2 | 1.55 — 153 of 173 |
| cap/join | square/miter | round/round — 175/177 |

Because the box was tight-cropped, a square `size-[16px]` scaled the glyph to
12.8px tall where a standard icon shows ~8.6px — about 50% larger, with a
relatively ~85% heavier stroke. The lucide icon it replaced was a 24-box at 50%
fill, which is why this only appeared after the migration.

`chevron-down`, `chevron-right` and `chevron-left` are rebuilt as the exact
mirror and transpose of `chevron-up`, which already sets the house standard, so
their optical weight is identical to that sibling by construction rather than by
eye.

Changing the viewBox is not safe on its own: 36 call sites sized these to the
old tight aspect (`h-[7px] w-[9px]`, `h-[6px] w-[10px]`, ...), which would
letterbox against a square box. All of them move to `size-[14px]`, the documented
default and the app's dominant size (212 uses). Every one of the 94 chevron call
sites is now square-sized.

Two of those were only reachable through indirection and would have regressed
silently: `STYLES.chevron` in the terminal's structured output, and the
sidebar-section chevron inside a multi-line `cn()`. The dropdown submenu chevron
carried no size at all and was relying on the icon's intrinsic 6x10 — it would
have jumped to 24x24.

Also unifies `folder-input`, which carried both 1.55 and 2 within one icon.

Docs consolidation, same theme — it was forking shared components:
- `SidebarChevron` was a private inline copy of the old 6x10 chevron; it now
  wraps the shared `ChevronRight`.
- `ThemeToggle` inlined lucide's sun and moon at strokeWidth 1.5; both now come
  from `@sim/emcn/icons` at the house 1.55.

Docs inline `<svg>` files drop from 9 to 7; the remainder are the brand icon set,
the logo, OG-image generation and bespoke hand-positioned shapes.

Left alone: 16 icons whose stroke or box still differs. They are fill-based
glyphs and brand marks (`sim`, `wordmark`, `folder`, `more-horizontal`, ...)
where changing the stroke means redrawing the icon — that wants visual review,
not a sweep.

Verified: 23/23 type-check (--force), biome, api-validation, 18361 tests, and
production builds of both apps.

* docs(emcn): correct the chevron geometry left in two comments

ChipChevronDown's TSDoc still described centring a 10x6 glyph, and the
enterprise sidebar's chip-parity comment still cited a 6x10 chevron. Both
now read 14px, matching what the components actually render.
2026-08-04 12:38:10 -07:00
Waleed fb7fd5b621 chore(copilot): remove the training controls feature (#6257) 2026-08-04 12:10:03 -07:00
Waleed 63faeb555f chore(deps): bump undici to 7.29.0 to close 5 security advisories (#6255) 2026-08-04 11:34:43 -07:00
Vikhyath MondretiandSim Pi Agent f079c72923 feat(library): Best AI Agents for Executive Assistant Tasks (#6254)
Co-authored-by: Sim Pi Agent <pi@sim.ai>
2026-08-04 11:28:36 -07:00
WaleedandBohdan Vilishchuk 47f5fee8cb fix(setup): launch the docker app the CLI is actually pointed at (#6253)
* fix(setup): detect OrbStack vs Docker Desktop before relaunching the daemon

ensureDocker() always ran `open -a Docker` to relaunch a stopped daemon on
macOS, which silently no-ops for OrbStack users (no Docker.app bundle
exists), leading to a misleading "GUI license acceptance" timeout error.
Now it checks the docker CLI's active context first (accurate regardless
of install location) and falls back to checking for OrbStack.app, so the
wizard launches and messages the app that's actually installed.

* fix(setup): don't let an installed OrbStack override an explicit Docker Desktop context

macDockerApp() fell through to the OrbStack.app existence check whenever
docker context show returned anything other than "orbstack" — including a
known, explicit context like "desktop-linux". With both apps installed but
Docker Desktop active and stopped, this launched OrbStack while daemonUp()
kept polling Docker Desktop's socket, timing out with OrbStack-flavored
guidance for a Docker Desktop problem.

The path fallback now only runs when the context command gives no answer
at all (null); any resolved context is trusted outright.

Flagged identically by Greptile and Cursor Bugbot on PR #6250.

* fix(setup): fall back to the installed app when the context isn't OrbStack

Context detection only fell back to the app bundle when `docker context
show` failed outright, so an OrbStack-only Mac sitting on the `default`
context still resolved to Docker Desktop — the same 90s hang this fix
exists to remove. Treat an explicit OrbStack selection as the only
positive context signal and otherwise pick whichever app is installed.

Read `DOCKER_HOST` first: it overrides the active context, so the
context name is not authoritative while it is set.

* fix(setup): require OrbStack to be installed before selecting it

A context or DOCKER_HOST left behind by an OrbStack uninstall selected an
app that can never launch, turning a working Docker Desktop start into a
guaranteed 90s timeout. Gate the OrbStack signal on the bundle being
present and fall through to whichever app is.

Look in ~/Applications as well as /Applications while here — Homebrew
casks honour --appdir, so a user-local install is not unusual and a
hardcoded /Applications check would misread it as "not installed".

* fix(setup): resolve the docker app through LaunchServices, not fixed paths

A Homebrew `--appdir` can put OrbStack anywhere, so enumerating install
directories will always have a tail that reads a present app as missing
and sends setup to the wrong one. Fall back to LaunchServices when the
well-known directories miss: that is the same lookup `open -a` performs,
so availability now agrees with what the launch will actually do.

* fix(setup): settle the docker app with open(1) instead of probing for it

`path to application` can raise a modal "Where is …?" picker when the name
does not resolve, which in a terminal wizard reads as a hang. Drop it: the
launch itself already answers the question, since `open` exits non-zero
when macOS knows no such app, instantly and without UI.

That inverts the design. Rather than predict which app is installed and
then launch it, pick a provider, try to start it, and let the exit code
correct a guess — so the directory probe no longer has to enumerate every
possible install location to be right.

An explicit OrbStack selection is now never redirected to Docker Desktop.
The CLI is addressing OrbStack's socket, so `docker info` keeps failing no
matter how well Docker Desktop starts; the earlier fallback only replaced
a 90s timeout with a differently worded one. Say the context is stale and
how to fix it instead.

* fix(setup): honour `required` when the docker app fails to launch

db.ts and redis.ts call ensureDocker(false) and branch on the boolean to
offer an external Postgres or Redis instead. Throwing past that aborts the
whole wizard when a working non-Docker path was on the table, so every
post-confirm failure now warns and returns false unless Docker is required.

That covers the 90s-timeout throw too, which ignored `required` before this
branch existed — leaving it as the one path that still aborts would make
the flag mean two different things in one function.

Also name DOCKER_CONTEXT in the stale-selection hint. It overrides the
config context, so `docker context use` alone leaves the CLI pointed at
OrbStack and the next run fails identically.

* improvement(setup): don't tell CLI-runtime users to install Docker Desktop

Having the docker CLI but neither GUI app is exactly what a colima or
Rancher Desktop user looks like, and the failure told them to install
Docker Desktop — advice for a problem they don't have. Name the situation
accurately and add starting an existing runtime as an option.

---------

Co-authored-by: Bohdan Vilishchuk <iamtheflex@gmail.com>
2026-08-04 11:21:27 -07:00
Waleed 3d8b2ed050 improvement(docs): inherit the platform border and font-weight scales (#6252)
* improvement(docs): inherit the platform border and font-weight scales

#6241 consolidated the app's neutral border tokens and flattened its
font-weight scale. `apps/docs` was carrying an untouched copy of the
pre-migration values, so the two have visibly drifted — the docs `@theme`
block already declares it "mirrors apps/sim/tailwind.config.ts", so the
drift is against stated intent rather than a deliberate divergence.

Borders — same consolidation as the app:
- `--border` #dedede -> #d8d8d8 (light), #333333 -> #444444 (dark)
- `--border-1` and `--border-muted` become aliases of `--border`, so the 19
  existing `var(--border-1)` consumers pick up the unified colour without
  being touched
- `--divider` is retired; its single consumer moves to `--border`

Font weights — the arbitrary values the app dropped:
- `font-[480]`/`font-[470]`/`font-[500]` -> `font-medium`, `font-[430]` ->
  `font-normal`, `font-[600]` -> `font-semibold`

The navbar's active tab and its invisible width-reserving ghost both used
`font-[480]`; they move to `font-medium` together, so the anti-layout-shift
trick still holds.

Deliberately NOT ported: the app's `--border-width` hairline (0.5px on hi-dpi).
The app wires it through `borderWidth.DEFAULT` in a Tailwind v3 JS config; docs
is Tailwind v4 CSS-first, which hardcodes `border: 1px` in the utility with no
theme key, so matching it means overriding a Tailwind utility. That is a
site-wide visual change and wants its own PR with visual review.

Also unchanged: the inline SVGs. `components/icons.tsx` (328) is the brand and
integration set, `sim-logo` is a brand mark, and the handful of remaining
shapes are bespoke and positioned by hand. Docs already consumes
`@sim/emcn/icons` in the 15 places where a shared icon is the right call, and
imports zero lucide.

* improvement(docs): finish the platform token sweep

Follow-on within the same PR. A full comparison of every custom token docs
rolls against the platform found three more classes of drift.

Text scale — #6241 retuned these and docs kept the old values:
- `--text-body` #3b3b3b -> #434343 (light), #cdcdcd -> #c1c1c1 (dark)
- `--text-muted` #707070 -> #7a7a7a (light), #787878 -> #6e6e6e (dark)
- `--text-icon`  #525252 -> #5a5a5a (light), #a0a0a0 -> #969696 (dark)

Docs and the app now agree on all 62 shared token names, with zero divergent
values.

Missing shared-component tokens. Docs renders `@sim/emcn` (Badge, Chip,
ChipLink) and `@sim/workflow-renderer` (block, subflow and note views), but
never defined 14 of the tokens those components reference — an undefined
`var()` silently falls back to `currentColor`, so the failure is invisible
until the branch that uses it renders. Several are live: `--warning` on an
edited subflow, `--caution` on inline code inside a note, `--text-placeholder`
on an empty note, `--border-success` on a successful run edge. Added with the
app's values, along with `--text-icon-muted` (new in #6241) and the four Badge
palettes docs lacked (teal, cyan, pink, blue-secondary), so any variant renders
correctly rather than being one prop away from black.

Type scale — docs declares micro/xs/caption/small/base/md in `@theme` but 20
call sites bypassed it with identical raw values (`text-[13px]`,
`text-[0.8125rem]`, `text-[12px]`, `text-[15px]`, `text-[10px]`). Each now uses
the token; every value is byte-identical, so this is a rename, not a restyle.
The class reordering in the same files is biome's `useSortedClasses` reacting
to the rename — verified as a pure permutation, with the class multiset
unchanged in every file.

Deliberately left alone:
- `#33C482` / `#2FB3FF` — brand-mark SVG fills and default props in demo data,
  not styling.
- The Ask AI button's inverted `#383838`/`#575757`/`#e0e0e0`/`#cfcfcf`. The
  platform's `--surface-inverted`/`--surface-inverted-hover` hold *different*
  values, so adopting them would restyle the control rather than tokenize it.
  Worth doing, but as a visual change with review.
2026-08-04 10:54:02 -07:00
Emir KarabegandWaleed Latif 9b9da81a27 improvement(platform): drop lucide-react for the in-house icon set, flatten the type and border scales, and retire scheduled tasks and workflow references (#6241)
* border styling

* improvement(platform): migrate off lucide-react, flatten the font-weight scale, and retire scheduled tasks and workflow references

* chore(platform): drop the dead schedule client layer and repair stale rule and skill docs

Follow-up cleanup for the platform commit, which removed the workspace
scheduled-tasks surface and migrated off lucide-react. Both left dead tails
that type-check clean, so nothing flagged them.

Six mutation hooks in hooks/queries/schedules.ts lost their only consumer when
the scheduled-tasks page was deleted: useDisableSchedule, useResumeSchedule,
useDeleteSchedule, useExcludeOccurrence, useUpdateSchedule, useCreateSchedule.
They are removed along with the three contract objects that served only them —
disableScheduleContract, excludeOccurrenceContract, deleteScheduleContract.

disableScheduleBodySchema and excludeOccurrenceBodySchema are deliberately
kept: both are members of scheduleUpdateSchema, the discriminated union the
live PUT /api/schedules/[id] route parses. Dropping them would collapse the
union and 400 the disable and exclude_occurrence actions.

The schedule-calendar tree and its utils stay unmounted for later reuse. Its
TSDoc now says so, since it has no importer and would otherwise read as dead
code on the next sweep.

The add-enrichment skill templated an import from lucide-react, a dependency
the platform commit deleted, so running it produced an unresolvable import. It
now points at @sim/emcn/icons, matching all five shipped enrichments. The
emcn-design-review skill and several rule files still pointed at
apps/sim/components/emcn/**, which moved to packages/emcn/**.

Also corrects the documented Chip variant list — it advertised a ghost variant
that never existed and omitted border — repoints the sim-url-state date-parser
example at an inline snippet now that its source file is gone, and normalizes
the one strokeWidth the icon migration left at 1.5 in bubble-chat-delay.

* fix(platform): mark the resource chrome as client components

`skills/page.tsx` is a Server Component, and this branch moved its
`IntegrationTabsHeader` import onto the `@/app/workspace/[workspaceId]/components`
barrel. That barrel re-exports `SortDropdown` from `resource-options`, which
calls `useState`, so the server graph now reaches a client-only module and
`next build` fails. `resource-header` has the same latent problem (`useState`,
`useEffect`, `useRef`).

Both files are genuinely client components, so they get the directive rather
than the page dropping the barrel import — local feature barrels are the
convention here.

Also drops a stale `lucide-react` mention now that the dependency is gone.

* chore(scheduled-tasks): remove the scheduled-task logic

Scheduled tasks are retired. This removes the `sourceType = 'job'` half of
`workflow_schedule` from the application, leaving the workflow Schedule
trigger (`sourceType = 'workflow'`) untouched.

Gone:
- the job orchestration layer (`lib/workflows/schedules/orchestration.ts`)
  and the agent-job runner in `background/schedule-execution.ts`
- the job claim/dispatch half of the schedules execute tick
- POST /api/schedules (job creation) and the job branches of
  GET /api/schedules and PUT/DELETE /api/schedules/[id]
- the copilot job tools and handlers, the `scheduledtask` resource type and
  chat-context kind, and the VFS `jobs/` materialization
- the scheduled-task analytics events and the job variant of the
  schedule-disabled email

Kept on purpose: `scheduled-tasks/components/schedule-calendar/**` and
`scheduled-tasks/utils/**`, which the agents module will reuse.

`packages/db/schema.ts` is deliberately untouched — the columns stay for now
and come out in a follow-up with a proper expand/contract migration.

The generated copilot catalog and VFS snapshot types are regenerated from
the matching copilot PR, which removes the tools and the `jobs` snapshot
field at the source.

Verified: 23/23 type-check, biome, api-validation, production build, and the
full vitest suite (18361 passing; the one failure in
executor/handlers/pi/cloud-review-tools.test.ts predates this branch).

* fix(sidebar): derive the settings and switcher widths from SIDEBAR_WIDTH

This branch moved `SIDEBAR_WIDTH.DEFAULT` from 248 to 238 but left two
hardcoded `248px` chrome widths behind, so both sat 10px wider than the live
sidebar:

- the workspace-switcher menu, which is meant to line up with the sidebar
  column it drops out of
- the standalone settings sidebar, whose own comment says to keep it in step
  with the in-workspace chrome

Both now read `SIDEBAR_WIDTH.DEFAULT` directly rather than repeating the
number, so the next change to the constant cannot leave them stale again.

* fix(schedules): stop the API accepting actions it no longer handles

Adversarial pass on the scheduled-task removal found a real regression in
PUT /api/schedules/[id].

Removing the job-only `update` and `exclude_occurrence` handlers left them in
`scheduleUpdateSchema`, so those bodies still parsed. The handler chain is
`disable` first and then an unguarded fall-through to reactivate, so an
`action: 'update'` request would have silently REACTIVATED the schedule
instead of being rejected.

Both actions are dropped from the discriminated union, so `parseRequest` now
rejects them with a 400. Their bodies, response types and the orphaned
`createScheduleContract` (its POST route is gone, and nothing imported it)
go with them.

* chore(landing): retire the scheduled-tasks marketing surface

The feature is gone from the product, so the marketing pages stop selling it.

- deletes the `/scheduled-tasks` landing page and its calendar-loop hero, and
  the `LandingPreviewScheduledTasks` panel
- drops the view from the landing preview: the `SidebarView` member, the nav
  entry and its now-unused Calendar icon, the callout label, both render
  branches, and the staged chat copy in `workflow-data`
- removes the navbar and footer links and the sitemap entry
- removes the route from `LANDING_ROUTES`, the COEP exemption list that must
  list every `app/(landing)` route

`/scheduled-tasks` is indexed, so it 301s to `/workflows` rather than starting
to 404 — that is the surface that still carries scheduled execution via the
workflow Schedule trigger.

Left alone deliberately: `demo-scheduler` is the Cal.com booking embed for the
demo page, unrelated to this feature, and the scheduling library article is a
generic SEO piece that never pitched it.

* perf(chat): stop the resource picker fetching schedules it no longer shows

Dropping the `scheduledtask` group from the add-resource dropdown left
`useWorkspaceSchedules` behind, so the picker still issued a workspace
schedules request whose result never reached a group.

Worse than a wasted request: `schedulesPending` was still in the hydration
gate, so the whole picker waited on that response before it could settle, and
`schedules` was still a `useMemo` dependency, re-running the group build when
it resolved.

The hook and its route stay — `/api/schedules?workspaceId=` still correctly
lists workflow schedules, unlike `createScheduleContract`, whose route this
branch removed.

* chore(scheduled-tasks): drop the leftovers the removal stranded

An independent audit of the branch turned up dead code and stale docs that the
compiler cannot see — nothing behavioural, but all of it rots silently.

- README still sold the feature: the "Scheduled tasks" tile, the prose listing
  it as a workspace surface, and the now-unreferenced screenshot. The landing
  surface went in c61770a8c; this tile was missed.
- `resource-content.tsx`: `SCHEDULE_STATUS_LABEL`, `formatScheduleInstant` and
  `ScheduledTaskField` were orphaned when the schedule render branch went.
- `computeNextRunAt`: zero callers, including tests — its only consumer was the
  removed agent-job runner.
- `applyScheduleUpdate`'s `allowCompleted` option: no call site passes it, and
  its comment described self-completion, which no longer exists. The guard stays
  (legacy `sourceType='job'` rows still carry `status='completed'` until the DB
  follow-up); it is simply unconditional now.
- Three TSDoc blocks still described a create-job route and "opening a
  scheduled-task artifact".

Type-check re-run with --force, since a cached turbo replay is not a check.

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-04 10:28:00 -07:00
Waleed 2977db5133 fix(deps): revert next to 16.2.12, its 16.3.0 optimizer deletes live code (#6242)
Next 16.3.0's Turbopack optimizer models a bare `return <asyncCall>()` tail
call inside an async function as returning the promise object, then propagates
that always-truthy fact through the caller's `await`. Where the result feeds an
`if (x)` whose every branch returns, it concludes the branch is always taken and
deletes everything after it from the emitted bundle.

Two sites shipped to production that way:

- `POST /api/credentials` lost its entire create path — the transaction, the
  org locks, the insert, the audit, the 201. A first-time create fell into the
  existing-credential branch and threw on `existingCredential.id`, so every new
  credential 500'd.
- `upsertAsyncToolCall` collapsed to `async () => await getAsyncToolCall(id)`.
  The insert is simply gone; it returns null for every new async copilot tool
  call. Silent — no error, no failed request.

A differential scan of 71,266 source string literals across `.next/server` and
`.next/static`, comparing images built from the same commit on 16.2.12 and
16.3.0, found exactly these two and nothing else. That scan cannot see dropped
branches with no distinctive string literal, which is why the version goes back
rather than the two sites being patched alone.

Both are also hardened with `return await`, verified to defeat the miscompile in
a minimal reproduction. The TypeScript toolchain cleanup from the original bump
(dropping @typescript/native-preview, `useTypeScriptCli`) is kept.
2026-08-03 22:31:43 -07:00
Theodore Li 40cbee4934 improvement(skills): ask for feature flag granularity (#6238) 2026-08-03 23:45:20 -04:00
Waleed 856fe0ffb6 fix(docker): upgrade bun to 1.3.14 (#6236)
* fix(docker): upgrade bun to 1.3.14 to unbreak the Next 16.3.0 server

Bun 1.3.13 cannot load Next 16.3.0's compiled server runtime. The app container
runs the Next server under Bun (`oven/bun:1.3.13-slim`, `bun apps/sim/bootstrap.js`),
so every app-page render threw and `/api/health` returned 500:

  ⨯ Error: Failed to load external module
    next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:
    TypeError: Expected CommonJS module to have a function wrapper.
    If you weren't messing around with Bun's internals, this is a bug in Bun

Isolated to Bun, not Next, by loading that exact module in the real images:

  Next 16.2.12 + Bun 1.3.13 -> loads (why staging was fine before)
  Next 16.3.0  + Bun 1.3.13 -> CJS wrapper error
  Next 16.3.0  + Bun 1.3.14 -> loads

Bun 1.3.14 is the current stable and already fixes it, so this bumps every pin
rather than reverting the framework upgrade, which would only defer the same
latent Bun bug to the next attempt.

Why no gate caught it: local dev machines and this bump's own verification run
Bun 1.3.14, while the container and CI pinned 1.3.13 — and CI only *builds* the
image, it never boots one and probes `/api/health`. A container smoke test in CI
would have caught this before merge; that is worth adding separately.

* fix(docker): align the remaining bun pins with 1.3.14

Two pins were missed in the first pass because the search was scoped to
docker/, package.json and .github/workflows/:

- .devcontainer/Dockerfile still built on oven/bun:1.3.13-alpine
- PI_BUN_VERSION in apps/sim/scripts/pi-sandbox-packages.ts was still 1.3.13,
  despite being documented as mirroring the root packageManager field, so Pi
  sandbox images would have kept installing the Bun release that cannot load
  the Next 16.3.0 server runtime.

Fixed surgically rather than with a repo-wide replace: "1.3.13" also appears
inside SVG path data in apps/sim/components/icons.tsx and
apps/docs/components/icons.tsx, which a blind sed would have corrupted.
2026-08-03 19:23:52 -07:00
Waleed 894083390e improvement(agent): allow variable references in reasoning effort, verbosity, and thinking level (#6233)
* improvement(agent): allow variable references in reasoning effort and verbosity

Reasoning Effort and Verbosity were select-only dropdowns, so a workflow could
not sweep them from a variable or an upstream block the way it already can with
the model. Both become editable comboboxes, matching the model field directly
above them and the managed-agent selectors.

- switch both subblocks to `combobox`, keeping their fetched per-model option
  lists intact
- keep them visible when `model` itself holds a reference, since the concrete
  model id is only known at execution time and cannot be matched against the
  static capability list
- normalize the resolved level in the provider chokepoint so a reference that
  resolves to `"High"` or to nothing behaves sanely instead of hitting a
  provider 400

* improvement(agent): allow variable references in thinking level

Extends the same treatment to Thinking Level so all three model-tuning fields
behave consistently, and logs a level a model does not declare.

- switch `thinkingLevel` to `combobox` with the reference-aware condition
- normalize it alongside the other two; an empty resolve now takes the
  deliberate "send nothing" path rather than the incoherent half-state it hit
  before, and stays distinct from an explicit `none`
- warn when a level is not one the model declares, still forwarding it: Sim's
  per-model lists drive the pickers and can lag a provider, and a sweep needs
  the provider's own error rather than a silent fallback to the default

* improvement(agent): report a model level the sanitizer discards

A model bound to a variable or block reference only resolves at execution time,
so a run whose reference landed on a model outside Sim's catalogue had its
requested level cleared with no signal and quietly fell back to that model's
default. Dropping stays the safe default — a provider with no such parameter
rejects the whole request — but it is now reported.

- log the field, model, and value whenever an unsupported-field level is cleared
- cover both diagnostics, including that they stay quiet for a declared level
  and for the `auto` / `none` sentinels

* fix(providers): redact resolved level content from sanitizer diagnostics

The model-level fields accept environment and block references, so an
unrecognized level is not necessarily a mistyped level — it is whatever the
reference resolved to, which can be secret content. The diagnostics added for
dropped and undeclared levels echoed it straight into server logs.

- log a level only when the catalogue declares it somewhere, or it is an
  `auto` / `none` sentinel; anything else is reported by length alone
- stop discarding levels for a model the catalogue has never seen. Absent is
  unknown, not known-incapable, and a reference is exactly how a newly released
  model arrives before Sim catalogues it — the provider decides instead. Models
  the catalogue knows, and every dynamic-provider id, keep the protective drop

* fix(providers): redact the level in Anthropic's unsupported-thinking warning

Forwarding an undeclared level is deliberate, but it means the Anthropic adapter
receives it and interpolates it straight into its "not supported, ignoring"
warning. Since the field is reference-bound, that value can be whatever a
mistyped `{{ENV_VAR}}` or block reference resolved to — so the redaction added
for the sanitizer's own diagnostics was leaking one layer downstream.

- promote the level renderer to `providers/utils` as `describeModelLevel`, the
  single gate every site echoing a caller-supplied level goes through
- use it in Anthropic's warning and in both sanitizer diagnostics

* refactor(providers): drop the sanitizer's level diagnostics

The two warnings logged server-side, where the workflow author who set the level
never sees them, and the surprising case they described — a level discarded for
a model newer than the catalogue — is now fixed at the source rather than
narrated. They also carried the redaction that leaked resolved content before it
was caught, so removing them removes that surface entirely.

Levels still normalize, and still drop for a catalogued model that does not take
the field. `describeModelLevel` stays for Anthropic's unsupported-thinking
warning, which is a pre-existing log this feature newly exposes to resolved
reference content.
2026-08-03 18:56:12 -07:00
Waleed d05289cfa2 fix(providers): route 6-10MB attachments to the provider large-file path (#6232)
* fix(providers): route 6-10MB attachments to the provider large-file path

The inline base64 cap was 10 MB of raw bytes, but the execution payload store
refuses a single value above 8 MiB and base64 inflates by 4/3. Every raw file
over 6 MiB therefore produced a base64 string the store rejected — and the
rejection came from the base64 *cache* write, which threw and failed the run
with "Execution memory limit exceeded" even though the bytes had already been
read successfully. Because shouldUseLargeFilePath only fires above the inline
cap, 6-10 MB attachments had no path at all on any provider: they never reached
the OpenAI or Gemini Files API upload they were supposed to take.

Derive the cap from the payload-store ceiling instead of hardcoding it, and
degrade a refused cache write to "not cached" rather than failing a request
whose bytes are in hand. Every other size guard in the chain compares raw bytes
against maxBytes; only the Redis write sees the encoded size, which is why this
went unnoticed — and why it failed only where Redis is configured.

Also correct the provider ceilings against the vendors' current documentation:

- openai: 50 MiB -> 50,000,000. The gate is `size > maxBytes`, so 50 MiB
  admitted 52,428,800 bytes; the docs say each file must be *under* 50 MB.
- bedrock: had no entry and inherited the inline cap, which is above what
  Converse accepts (3.75 MB per image, 4.5 MB per document).
- groq: 20 MiB -> 20,000,000, and modelled as the request cap the docs
  actually describe rather than a per-file MiB ceiling.
- fireworks: had no entry; its 10 MB budget is on the base64 total, so the
  raw-byte equivalent is 7.5 MB.

Add perRequestMaxBytes for the combined ceilings, enforced before any upload
spend, and cover the OpenAI upload path end to end — it had no test at all.

* chore(deps): upgrade @google/genai to 2.13.0 and @anthropic-ai/sdk to 0.115.0

@google/genai 2.x reworks the Interactions API, which the Gemini deep-research
provider is built on. Migrate it:

- `Interaction.outputs` (a flat content array) is now `steps`, a discriminated
  timeline; the report text lives in the `model_output` steps' text content,
  alongside thought and tool steps we skip.
- `Usage.total_reasoning_tokens` is now `total_thought_tokens`. The old code
  already fell back to that name through a cast, so this just makes the field
  the SDK actually returns the typed one.
- SSE events renamed: `content.delta` -> `step.delta`, `interaction.start` ->
  `interaction.created`, `interaction.complete` -> `interaction.completed`.
  The new event types are discriminated, so the payload casts are gone.

Both `interactions.create` calls also stop annotating their params with
`Interactions.CreateAgentInteractionParams{,Non}Streaming`. In 2.13.0 those
namespace aliases resolve to `CreateAgentInteraction`, whose `stream` is a
plain `boolean` rather than a literal — annotating with them erases the
discriminant and the call resolves to the union-returning overload, so the
result is typed as `Interaction | Stream` at every use. An inline
`stream: true as const` keeps the correct overload.

Neither upgrade required a `minimum-release-age` waiver: 2.15.0 and 0.115.0
were checked and 2.13.0 is the newest genai release clearing the 7-day window.

* chore(deps): upgrade openai to 7.0.0

v5 is the only major with real breaking changes for us; v6 widened a Responses
output type and v7 only raised the Node floor to 22, which apps/sim already
requires. Three things needed fixing:

`ChatCompletionMessageToolCall` became a union of function and custom tool
calls, and the custom variant has no `function` field — 43 unguarded `.function`
accesses across the OpenAI-compatible providers. Narrow once at each
`message.tool_calls` read through a shared `isFunctionToolCall` guard rather
than casting at every use.

That guard deliberately tests for the `function` payload instead of
`type === 'function'`. Many OpenAI-compatible vendors omit `type` on tool calls
entirely — our own fixtures do — so discriminating on it type-checks perfectly
and then silently drops every tool call those providers return.

`ChatCompletionCreateParams.verbosity` narrowed from `string` to a literal
union, and the Responses API's output and input item unions now diverge on
members Sim never emits (computer-use call outputs, whose `status` admits
`failed`, and the `AdditionalTools` escape hatch). Echoing output back as input
is what a tool loop is supposed to do, so that conversion is asserted once in
convertResponseOutputToInputItems and the streaming loop now routes through it
instead of pushing raw output items.

The hand-rolled multipart upload in file-attachments.server.ts can now be
replaced with the SDK's typed `expires_after` — left for a follow-up so this
commit stays a pure upgrade.

* fix(providers): correct defects found auditing the attachment and SDK changes

The mechanical rewrite that added `isFunctionToolCall` to every `tool_calls`
read also rewrote three truthiness guards, where the filtered array was
computed, discarded, and the unfiltered value used in the body. Filter once and
use that value. The helper also landed between `trackForcedToolUsage`'s TSDoc
block and its declaration, leaving that block documenting the wrong function.

Raise the Bedrock ceiling from 3.75 MB to 4.5 MB. Converse caps an image at
3.75 MB and a document at 4.5 MB, and a single `maxBytes` cannot express both.
Taking the lower bound looked conservative but regressed 3.75-4.5 MB documents,
which Converse accepts and which work today. At the document bound every size
that works now still works, and only genuinely-too-large files are rejected
early; oversized images in that band keep surfacing as a Bedrock API error,
exactly as they do without the entry.

Both limits re-verified verbatim against the primary docs: Converse's Message
reference ("Each image's size ... no more than 3.75 MB", "Each document's size
must be no more than 4.5 MB") and Fireworks' vision guide ("Total base64-encoded
images must be less than 10MB").

* fix(providers): keep every attachment that works today working

Auditing the routing change for backwards compatibility turned up two bands it
silently broke.

Lowering the single inline cap made the upload path mandatory above ~6 MB, but
every large-file path reads its bytes back out of cloud object storage. A
deployment without it — local dev, any disk-backed self-host — inlines those
files as base64 today and would have started failing outright with "requires
cloud file storage". Split the one number in two: the inline ceiling stays at
10 MiB, and a separate threshold marks where an upload becomes *preferable*
because the base64 copy no longer fits the payload store. Where no upload path
is reachable, base64 hydration now runs to the inline ceiling as before, and a
missing cloud-storage backend leaves the file for the inline path instead of
throwing.

The two strategies also cross over at different sizes now. `files-api` carries
every type the provider already accepts, so it takes over at the lower
threshold. `remote-url` only fetches images and PDFs, so switching early would
have started rejecting 6-10 MB text documents that inline fine today; it takes
over only once inlining is genuinely impossible.

Revert the Groq ceilings. Its published "20MB" governs a request carrying an
image URL, and on this path the body holds only the URL, so it cannot bind on
the files maxBytes guards. Groq documents no limit on the image it fetches, so
tightening the per-file cap to 20,000,000 and summing raw bytes against the
request cap would both reject uploads that work today on no documented basis.

* fix(providers): drop the provider ceiling changes and close the audit findings

A six-agent line-by-line audit against the vendors' live docs found the
`models.ts` ceiling work was not the strict improvement it was written as, so
all of it is reverted:

- bedrock's 4.5 MB cap broke video. Converse takes image, document AND video
  blocks, and video is allowed 25 MB base64 — a single `maxBytes` cannot
  express three content classes, and every 4.5-10 MiB `.mp4` that works today
  would have started failing.
- openai's combined 50 MB cap is the FILE-input limit. Image inputs are
  governed separately at 512 MB / 1500 images, so summing every attachment
  rejected eight 8 MB PNGs that OpenAI documents as legal.
- fireworks' per-file ceiling was unreachable behind the request budget, while
  the upload picker went on advertising it — a size the UI accepts and
  execution always rejects.
- The whole `perRequestMaxBytes` feature goes with them: it summed raw bytes
  against caps that are variously on encoded bytes, on one content class, or on
  a body that carries only URLs, and it double-counted a file referenced from
  several messages even though the uploader dedupes by key.

Only openai's per-file `maxBytes` stays corrected, to decimal 50,000,000 — the
one number a vendor states unambiguously and writes no MiB against.

Also fixed, all found by the same audit:

The hydration cap stopped short of where `remote-url` actually switches over,
so 6-10 MiB attachments on anthropic/openrouter/xai/groq/together/baseten/vllm
had neither base64 nor a handle and failed outright — the very band this branch
exists to fix. Both decisions now come from one function so they cannot drift.

Eight more sites where the mechanical rewrite computed a filtered array and
then read the unfiltered one (deepseek, sakana, nvidia, kimi), leaving those
providers without the narrowing they appear to have.

`isFunctionToolCall` threw on a null or primitive `tool_calls` entry, because
`in` requires an object — reachable exactly on the self-hosted gateways this
filter was added for. It is now total, and all 32 test mocks match it rather
than being quietly more permissive.

`checkForForcedToolUsage` in utils/litellm/mistral evaluated the response before
the `tool_choice` test, turning a tolerated malformed body into a TypeError on a
path that never used to touch it.

Gemini: `satisfies` restores the excess-property checking the dropped
annotations removed, the poll loop recognises the terminal statuses v2 added
instead of spinning for an hour and reporting a timeout, and the streaming doc
block no longer names five events that were renamed six lines below it.

* fix(providers): report attachment limits in the unit vendors publish

The size ceilings are decimal MB — that is how OpenAI, AWS and Fireworks all
write them — but the error messages divided by 1024², so OpenAI's 50 MB cap
was reported to the user as "48MB". Someone shrinking a 49 MB file to get
under it was chasing a limit that does not exist.

One formatter, used by all three messages, so the file size and the ceiling in
the same sentence are always in the same unit.

* fix(providers): derive the limit unit from the ceiling it belongs to

The previous commit fixed OpenAI's "48MB" by dividing every ceiling by 10⁶ —
which broke the other seven. Only OpenAI's constant is decimal; anthropic,
google, together and openrouter are 50 MiB, baseten and vllm 25 MiB, groq and
xai 20 MiB. Rendering those as decimal MB overstated each by ~5%, so a 21 MB
file on groq was rejected with "(21MB) exceeds the 21MB limit" — a sentence
that contradicts itself and sends the user to shrink a file to a size that is
still over. Same class of bug as the one being fixed, sign flipped.

Both figures now render through one unit taken from the ceiling, so the number
a user is told is the number the vendor publishes and the two sizes in a
sentence are always comparable. Tested against every ceiling in the registry
rather than only the values that happened to round cleanly.

Two more from the same audit:

A file with a missing or zero declared size was stranded on a files-api
provider: hydration bailed on the real byte length while `shouldUseLargeFilePath`
saw `0 > threshold` as false, so it got neither base64 nor a handle and failed
as "may no longer be accessible" — a size failure wearing an access failure's
message. Uploads read the real bytes and enforce the ceiling themselves, so an
unknown size now routes to one.

The oversized-attachment error blamed the provider for a deployment problem:
a files-api provider on a host without cloud storage reported that the provider
"has no large-file upload path", which is not true of the provider.

`isFunctionToolCall` only proves `function` is present, never that it is well
formed, so the trace enricher is defensive again about a hollow payload without
giving up the compile-time gate. The 32 test mocks now match production exactly.

* fix(providers): stop an over-limit size rendering as the limit itself

Deriving the unit from the ceiling fixed the 5% error but left the precision
fixed at two decimals, so a file one byte over a 20 MiB cap still printed
"(20.00MB) exceeds the 20MB agent attachment limit" — the same self-contradicting
sentence, now in a ~5 KB band above every ceiling in the registry. The size
rounds up and the ceiling rounds down, so the two can no longer collide.

The test that was supposed to guard this asserted a file 0.03MB over and an
OpenAI file that was under the limit — neither anywhere near the band — so it
passed while the bug was live. It now walks `limit + 1` for every ceiling, and
goes red against the old rounding.

The reason clause added last commit also claimed a deployment had no cloud file
storage whenever the strategy was not inline. A generated document on a
remote-url provider reaches that same error with storage fully configured,
because a signed URL points at the generation source rather than the rendered
artifact — so it was told something false about its own deployment. That case
now names itself.

* fix(providers): order the attachment failure reason by how general the cause is

The generated-document arm was checked first, so it won over both other causes
and told users two things that were not true.

On an inline-strategy provider — bedrock, mistral, ollama, fireworks, litellm,
vertex, kimi — there is no upload path for any file, generated or not, but the
message blamed the document format and implied a plain PDF would go through.

On openai or google with cloud storage unconfigured it was simply false: a
generated document does take the Files API path there, and that exact file
uploads fine once storage exists. The one actionable fix was hidden from the
operator.

A provider with no upload path cannot be helped by changing the file, and a
deployment with no object storage cannot reach any upload path whatever the
file is, so both now outrank the format-specific case — which is left saying
only what is true of it: a signed URL points at the generation source rather
than the rendered file.

The formatter is unchanged. It was brute-forced over every real ceiling and
three million random pairs with no collision or inversion, but the test's six
ceilings all divide to exact integers, so floor, round and ceil are
indistinguishable on them and the limit-side rounding was unpinned. A ceiling
with a fractional remainder now covers it.
2026-08-03 18:39:27 -07:00
Waleed ed17bb2bac chore(deps): upgrade next to 16.3.0 and clean up the TypeScript toolchain (#6235)
Bumps next, @next/env and the @next/swc-* optional deps to 16.3.0 across the
root overrides, apps/sim, apps/docs and packages/emcn.

Two config notes worth keeping:

- `experimental.turbopackFileSystemCacheForBuild`'s default flipped false ->
  true for stable in 16.3.0, so our explicit `false` is now load-bearing rather
  than defensive. Without it this bump would have silently re-enabled a build
  cache measured 3.2x slower on this codebase (#6078). Comment updated to say so.
- `experimental.useTypeScriptCli: true` is now pinned. TypeScript 7 ships no
  JavaScript compiler API until 7.1, so Next's default checker cannot run and
  needs the project-local `tsc` CLI instead. 16.2.12 was silently skipping
  build-time type checking entirely because it detected @typescript/native-preview
  and short-circuited the stage ("Finished TypeScript in 138ms"); pinning the flag
  keeps that from drifting back.

TypeScript toolchain cleanup that the upgrade makes possible:

- Drop @typescript/native-preview from apps/sim and apps/docs. It was the
  pre-release channel for TS 7 and is superseded by typescript@7 (nightlies now
  ship as typescript@next), and its presence is what suppressed build type checks.
- Align packages/browser-protocol and packages/terminal-protocol from
  typescript ^5.7.3 to ^7.0.2 so every workspace is on one compiler version.

apps/sim keeps @typescript/typescript6 as a production dependency: the function
sandbox at app/api/function/execute dynamically imports the TS 6 compiler API to
transpile user code, and TS 7 has no API to replace it yet.

Build and dev were benchmarked 3x per version on a byte-identical tree; the
upgrade is performance-neutral (build median 100s -> 99s, dev:full warm 10s -> 9s).
2026-08-03 18:30:11 -07:00
Waleed f210a6edc0 fix(execution): offload buffered event values under budget pressure (#6231)
* fix(execution): offload buffered event values under budget pressure

An execution buffers EVENT_LIMIT events inside a per-execution byte budget, so
a full ring only fits if events average under budget/EVENT_LIMIT. Values were
only offloaded to object storage at the shared 8 MiB cap, far above that, so a
run emitting large block outputs exhausted its budget within a few dozen events
and stayed pinned at its ceiling for the rest of its life.

Applying that ceiling to every run would be worse than the problem: the SSE
stream carries the compacted event and the terminal renders a ref only as a
preview, so ordinary block outputs would stop being readable live, and every
value would cost an object-storage write on the hot path. Engage the tight
ceiling only once a run has actually buffered past half its budget. A short run
keeps full-fidelity output and pays nothing; a runaway one stops accumulating.

Both bounds derive from the existing budget rather than being asserted, and
preserved UserFile base64 is exempt — it is an explicit request for inline
delivery, already bounded by its own cap and the strip-and-recompact fallback.

Also stop a failed resume-path buffer write from failing the run: it was
awaited bare, so the rejection propagated into the executor callback and failed
work that had already completed. The buffer only backs reconnect replay, so
degrade to live-only delivery the way the execute route does.

* fix(execution): measure pressure at write time and keep terminal status last

Pressure was read from bytes counted once a flush succeeded, but a burst is
compacted long before the scheduled flush runs — so the very batch that
exhausts the budget went through at the loose ceiling and was dropped instead
of offloaded. Count bytes as each event is compacted.

Separately, the terminal-alone retry stamped terminal status while entries
queued ahead of it were still unwritten. Terminal status is the reader's
end-of-run signal: a reconnecting client drains what is in Redis and closes, so
those entries were stranded behind a stream it had already finished with. Drain
the backlog first, then publish the terminal event.

* fix(execution): do not lose the backlog or publish terminal status early

Draining the backlog ahead of the terminal event left the terminal status armed,
so whichever chunk emptied the queue stamped the run complete before its
terminal event was written — the inverse of the ordering the drain was added to
guarantee. Disarm the status for the drain and restore it afterwards.

The drain's result was also discarded: a transient Redis failure requeues its
batch, and the unconditional reassignment that followed dropped those events
even though the budget never rejected them. Keep whatever could not be
persisted, and publish the terminal event alone only once nothing earlier is
still queued — failing otherwise lets the caller degrade, which records the
status without claiming the missing events arrived.

Leave eventId unset on a failed resume-path write. Assigning 0 was persisted by
clients as a reconnect cursor and rewound them to the start of the run.

* fix(execution): keep an event in the buffer when a pressure offload fails

Durable compaction runs before an event is queued, so a storage or metadata
failure dropped it from replay entirely — a reconnecting client would never see
it, even though the live path carried on. Offloading under pressure is only an
optimization that keeps a heavy run from exhausting its budget, so when the
value cannot be persisted, fall back to buffering it inline: exactly what the
run would have done before pressure engaged.
2026-08-03 17:23:14 -07:00
Waleed 41592dfbf1 fix(execution): stop the event buffer retaining a run-length backlog (#6229)
The Redis byte-budget branch in doFlush requeued the rejected batch and
rethrew, skipping the MAX_PENDING_EVENTS trim every other failure path
applies. The backlog then grew for the rest of the run and each retry
re-serialized it, so a wide parallel fan-out could drive unbounded heap
growth and stall the event loop.

Drop rejected chunks instead of requeueing, pace retries through the
existing backoff, and split batches that exceed the single-write cap so
an oversized batch can make progress instead of stalling forever.
Terminal status is now writer-scoped, since a concurrent scheduled flush
can be the loop that drains the final chunk, and a terminal event whose
batch was dropped is retried on its own rather than lost with it.

Record terminal stream meta when the terminal event cannot be buffered,
so reconnecting readers stop polling an active stream until their
deadline. Drop the unused reserve/release budget helpers.
2026-08-03 16:19:07 -07:00
Siddharth Ganesan 5ab5f2c7ed feat(browser, terminal): implement browser driver, password manager, terminal features (#6196)
* icon styling

* feat(desktop): isolate chat browser and terminal sessions

* feat(desktop): uncap browser and terminal tabs

* feat(desktop): polish browser and terminal resources

* improvement desktop

* fixes

* updates

* fixes

* fix

* update tests
2026-08-03 16:00:04 -07:00
Waleed 3de63c94e3 feat(self-host): align Docker Compose with Helm and overhaul self-hosting docs (#6225)
* feat(self-host): align Docker Compose with Helm and overhaul self-hosting docs

Docker Compose shipped no scheduler, so scheduled workflows, every polling
trigger, connector syncs, the outbox, and data drains silently never ran.
Adds a cron service running the same 18 jobs the Helm chart schedules as
CronJobs, and closes the remaining behavioral gaps between the two paths:
bundled Redis in the chart, no hosted plan caps in chart defaults, pinned
image tags, and fail-fast secrets. A CI check keeps the schedulers in sync.

Also rewrites the self-hosting docs: 14 new pages, 8 updated, reorganized
into Install / Configure / Operate.

* fix(self-host): drop bun install from chart CI, remove air-gapped and backup docs

The scheduler-parity check pulled a full dependency install into the
chart-validation job, which fails building isolated-vm on that runner.
Rewritten to use only node builtins so the job installs nothing.

Also removes the air-gapped and backup/restore pages, and stops pinning a
concrete release in the docs so the examples do not go stale each release.

* fix(helm): bundle Redis in secret-manager modes unless the URL is supplied

Suppressing Redis whenever a secret mode was active left those deployments
with no Redis at all — REDIS_URL is optional there and both shipped examples
omit it. The chart now steps aside only on a detectable signal: an explicit
app.env.REDIS_URL, an ESO remoteRefs.app.REDIS_URL mapping, or the new
redis.provideUrl=false opt-out for a pre-created Secret it cannot read.

* fix(compose): derive realtime BETTER_AUTH_URL from NEXT_PUBLIC_APP_URL

realtime read BETTER_AUTH_URL directly and fell back to localhost while
simstudio derived it from NEXT_PUBLIC_APP_URL, so setting only the public
origin left realtime authenticating against http://localhost:3000.

* fix(helm): deliver bundled REDIS_URL via ConfigMap so an operator value always wins

Injecting REDIS_URL as an inline container env made it beat every envFrom
source, so a REDIS_URL held in a pre-created Secret or synced by External
Secrets was silently shadowed and traffic moved to a fresh in-cluster Redis.

Kubernetes resolves duplicate envFrom keys by letting the last source win, so
the bundled URL now ships as a ConfigMap listed before the app Secret. Any
operator-supplied value overrides it without the chart needing to read it,
which also removes the redis.provideUrl flag the previous attempt required.

* docs(helm): spell out the egress rule external datastores need

The default NetworkPolicy allows 443 plus the bundled Postgres and Redis by
pod selector. Anything you run outside the chart on another port needs its own
rule, which is easiest to miss when REDIS_URL arrives via a Secret the chart
cannot inspect. Adds a copyable example to the production checklist and the
security guide.

* feat(helm): add networkPolicy.allowExternalEgress for managed datastores

The default policy allows 443 plus the bundled Postgres and Redis by pod
selector, so a managed datastore on another port needs a hand-written CIDR
rule — awkward when REDIS_URL arrives via a Secret the chart cannot inspect.

Adds an opt-in switch that drops the port restriction while still blocking the
cloud metadata endpoints. Defaults to false, keeping this chart stricter than
the common chart default of unrestricted egress.
2026-08-03 14:52:48 -07:00
Waleed 1242301bd3 improvement(emcn): share one emails/domains chip input across share and deploy modals (#6226)
* improvement(emcn): share one emails/domains chip input across share and deploy modals

Extracts the emails chip lifecycle out of ChipModalField type='emails' into a
standalone ChipEmailsInput, and points both the file share modal and the deploy
modal's chat tab at it instead of their hand-rolled TagInput wiring.

- add ChipEmailsInput (dedupe, normalize, format gate, paste, per-chip errors)
  with an allowDomains opt-in for bare @domain.tld entries
- share modal and deploy modal chat tab now use it; drop both hand-rolled
  add/remove/validate implementations and the dead emailError state
- move the shared allowlist policy into validateAllowlistEntry
- drop the "Add specific emails or whole domains" hint text
- give OutputSelect a size prop; the deploy modal chat tab uses the 30px chip
  trigger so it lines up with the Title field above it
- drop overflow-y-auto from the chat deploy form, which was promoting overflow-x
  to auto and rendering a stray horizontal scrollbar

* improvement(utils): one email syntax gate, drop the backtracking placeholder regex

Audit follow-ups on the emails chip input.

- move EMAIL_SYNTAX_REGEX and the new @domain pattern into @sim/utils/string as
  isValidEmailSyntax, so emcn and lib/messaging/email/validation.ts stop keeping
  byte-identical copies of the RFC 5322 regex
- allow single-label domains (@intranet) again — requiring a dot rejected
  entries the old startsWith('@') check accepted, which self-hosted
  deployments use. A lone @ and malformed labels stay rejected
- replace derivePlaceholderWithTags' /^Enter\s+(.+?)s?$/i with string ops;
  CodeQL flagged it as polynomial backtracking (js/redos). Verified identical
  output across the placeholder shapes in use
- forward the emails control's props explicitly instead of underscore-discard
  destructuring, matching ChipModalFileControl in the same file

* test(email): pin the allowlist entry rules

Covers isValidEmailSyntax's allowDomains branch (single-label domains stay
valid, malformed bare domains that the old startsWith('@') check accepted do
not), the 254-character cap, the DNS label limit, and validateAllowlistEntry
waiving address-level policy for bare domains. Verified both new rules fail
when the behavior is reverted.
2026-08-03 14:40:41 -07:00
Waleed 67e355b1ec chore(landing): remove the Russell Investments logo (#6228) 2026-08-03 14:34:59 -07:00
Waleed c759a889d8 refactor(voice): load STT availability through React Query (#6224)
* refactor(voice): load STT availability through React Query

useSpeechToText fetched `/api/settings/voice` inside an effect and stored
the result in useState behind a hand-rolled mountedRef guard: no cache, no
dedupe across mounts, and no AbortSignal, so the response was fetched and
parsed even after unmount. Two simultaneously mounted consumers issued two
requests. It also bypassed hooks/queries/**, which is where every other
server read in the app lives — and it escaped `check:react-query`, whose
audit only covers useQuery/useMutation call sites.

The value is server env read at request time, so it cannot change within a
session; the new hook uses an infinite staleTime and a caller-controlled
`enabled` so clients without the audio APIs never issue the request.

Hydration is unchanged: SSR renders unavailable, and the first client
render still resolves unavailable because `data` is undefined until the
fetch settles. No initialData, deliberately — adding it would break that.

mountedRef stays; it is still load-bearing for the streaming lifecycle.

* test(queries): unmount rendered roots between tests

renderHookWithClient created a React root per test but never tore it
down, so trees stayed mounted with live QueryClient observers until
worker teardown and async notifications could cross test boundaries.

Audited every test in the repo using createRoot: 51 of 53 already
unmount. The two that did not were both mine — voice.test.tsx here and
chats.test.tsx from #6223 — so both are fixed and the pattern is now
uniform.

* fix(voice): let a failed STT probe recover on a later mount

The app QueryClient sets retryOnMount: false and retry: 1, and
refetchOnWindowFocus only refetches stale queries — which an infinite
staleTime never becomes. So one transient failure cached the error for
the life of the client and hid the mic until a full page reload.

The effect this replaced refetched on every run, so retryOnMount: true
restores parity: no refetch after success, a retry per mount after
failure. Test asserts recovery under the app's real query defaults and
fails without the override.
2026-08-03 13:19:08 -07:00
Theodore Li 3f70096841 improvement(self-host): gate email verification on a mail provider, add self-host settings, land setup on signup (#6216)
* fix(auth): skip email verification when no mail provider is configured

Signup pushed /verify unconditionally, stranding self-hosted deployments
with no mail provider on a screen no email could ever satisfy. Derive one
server-side effective value (verification enabled AND deliverable) and read
it from Better Auth enforcement, signup routing, and the verify page.

* feat(settings): add a self-host section with the managed Chat keys link

Self-hosters had no in-app pointer to the managed service that issues their
Chat keys. New Settings > System > Self-host section, gated on `requiresSelfHosted`
so it is absent on hosted Sim, containing only that link.

* improvement(setup): land the wizard handoff on signup

A freshly provisioned deployment has no accounts and / renders the marketing
landing page, so the bare origin left operators hunting for the CTA. Single-source
the URLs and point every open-Sim handoff at /signup across all three modes.

* improvement(settings): mark the self-host section with a sprout

Server was already doing double duty for MCP servers and Mothership, and the
icon set ships no botanical glyph, so the mark is a text emoji.

* improvement(settings): draw the sprout as an emcn line icon, move to Platform

The emoji rendered in the platform's own colors, so it was the one glyph in the
nav that ignored --text-icon. Replaced with a hand-drawn emcn Sprout (24 grid,
1.55 stroke, currentColor) matching the house style, renamed the tab to
Self hosting, and regrouped it under Platform — self-hosting is deployment-wide,
not per-workspace. Still self-hosted-only.

* improvement(settings): drop the section header from self hosting

One row does not need a section label, and removing it takes the divider with
it. The body is now the Chat keys row and its managed-keys link, nothing else.
2026-08-03 15:58:15 -04:00
Waleed 1708173970 refactor(chat): clean up the deployed chat surface (#6220)
* refactor(chat): clean up the deployed chat surface

Eight-angle cleanup pass over the full contents of the chat surface and
the speech code that survived the voice-mode removal.

Dead code
- enforceChatRateLimit: added for the TTS relay in #6212, orphaned when
  #6215 deleted that route. Zero consumers.
- ChatToolCallStatus, ChatErrorType, and six unused CHAT_ERROR_MESSAGES
  keys (only GENERIC_ERROR and CHAT_UNAVAILABLE are read).
- scrollToMessage was declared and destructured by ChatMessageContainer
  but never used in its body; removing the prop also made the
  scrollToShowOnlyMessage branch unreachable, since the sole caller
  passed true.
- permissionState and the language prop on useSpeechToText: both
  write-only across the repo.
- The image branch in ChatFileDownload's renderIcon returned the same
  DefaultFileIcon at the same size as the fallback.
- chatKeys.status/detail: aliases of deploymentKeys nothing imported,
  and misleading since they root under a different key namespace.

Redundant state
- password-auth and email-auth each kept a boolean in lockstep with
  `errors.length > 0`; email-auth also validated on every keystroke and
  then immediately hid the result.
- file-download tracked hover in state to drive one opacity class; now
  group-hover. Verified emcn Button sets no `group` class of its own.

Memoization
- ChatMessageContainer's memo() could never bail: chat.tsx passes an
  inline arrow for scrollToBottom and displayMessages is a fresh array.
  Four of the five things that re-render ChatClient are its props
  anyway, so the memo is dropped rather than propped up.
- ClientChatMessage keeps its memo — it blocks markdown re-parsing —
  but loses the custom comparator, which compared proxies (a
  key:status fingerprint, files by length) and ignored attachments and
  type entirely. Default shallow compare on its single prop is both
  simpler and stricter.
- Six useCallbacks whose consumers are native DOM handlers or inline
  arrows, so nothing observed their identity.

Effects
- The scroll listener attached in an effect keyed on [chatConfig,
  authRequired] — values it never reads, standing in for "the container
  has mounted". It now attaches via a ref callback, so it no longer
  re-attaches on every config refetch.

Design system and a11y
- z-[100] -> z-[var(--z-dropdown)] (same value), shadow-lg ->
  shadow-medium, list styles from inline style to Tailwind classes,
  hover: -> hover-hover: on touch-reachable targets, Check sourced from
  emcn alongside its Duplicate pair.
- Accessible names on the remove-attachment, stop, and send buttons,
  which announced only as "button".
- Dropped a keyboard handler on a role='group' div with no tabIndex,
  where target === currentTarget was unreachable, and the Tooltip
  Provider wrappers and delayDuration, which emcn documents as
  no-op passthroughs.

* fix(chat): restore markdown list markers

The design-system pass swapped inline `listStyleType` for Tailwind
classes, but the edit that added `list-disc`/`list-decimal` silently did
not apply while the one removing the inline style did. With Preflight
setting `list-style: none`, every bullet and number in an assistant
response disappeared. `list-item` on the `li` sets display only, not the
marker type.
2026-08-03 12:53:04 -07:00
Waleed b0491f1161 fix(chat): invalidate deployment queries after a chat mutation (#6223)
PATCH /api/chat/manage/[id] calls performFullDeploy when the workflow has
drifted from its active deployment, so editing a chat can mint a new
deployment version. useUpdateChat invalidated only chatStatus and
chatDetail, leaving the deployment panel showing the previous version and
a stale "needs redeployment" indicator until the staleTime expired.

Both mutations now route through invalidateDeploymentQueries, the shared
helper the rest of the deployment surface uses. That also picks up
deployedState, which useCreateChat's hand-rolled list had omitted even
though performChatDeploy replaces the deployed workflow state.

Tests cover both mutations and were verified to fail against the previous
invalidation.
2026-08-03 12:52:48 -07:00
Waleed 36aace765e fix(auth): correct callback URL resolution across SSR and hydration (#6217)
* fix(sso): derive the SSO callback URL during render

`callbackUrl` was seeded into `useState('/workspace')` and overwritten from
a `useEffect` that read `searchParams`, so the first painted frame always
carried the default. On any deep link with `?callbackUrl=`, the "Sign in
with email" and "Sign up" links briefly pointed at `/workspace` instead of
the requested destination, and a click landing in that window navigated to
the wrong place.

- derive `callbackUrl` from `searchParams` during render; the validation
  gate is unchanged, so an off-origin or malformed value still falls back
  to `/workspace`
- keep the warning for a rejected value in an effect, now keyed on the
  param itself rather than the `searchParams` object, so it fires once per
  actual change instead of once per identity change
- add first-frame tests via `renderToString`, which runs no effects and so
  pins exactly the window the old code got wrong

* fix(auth): resolve callback URLs against the app origin server-side

`validateCallbackUrl` compared against a sentinel base
(`https://callback-url-validator.invalid`) when `window` was undefined, so
the server rejected every absolute URL — including the same-origin ones the
function documents as valid. A component deriving a callback URL during
render therefore produced one destination in the SSR markup and a different
one after hydration.

The exposure was not new to the SSO form: `login-form.tsx` and
`signup-form.tsx` already derive their callback URL during render on
`force-dynamic` pages, so both carried the same divergence.

- resolve against the deployment's own origin server-side, so the server
  reaches the same verdict the browser will after hydration
- fall back to the sentinel when the app URL is unset or unparseable, which
  keeps the server fail-closed: absolute URLs are rejected, as before
- cover the absolute same-origin case and the unset-app-URL fallback in the
  existing suite; all 15 open-redirect rejection cases are unchanged

* fix(env): drop the getBaseUrl browser-origin fallback

The fallback was added in #6214 as a safety net while the real cause — the
hosted env script losing its `beforeInteractive` strategy — was fixed in the
same PR. With the injection ordering restored, `window.__ENV` is populated
before hydration, so the fallback is unreachable in any correctly configured
deployment.

Guessing the origin was also unsafe in the one case it could still fire. An
opaque origin — a sandboxed iframe, and `/chat/*` is deliberately embeddable
— serializes to the string `'null'`, which is truthy, so `getBaseUrl()` would
have returned `'null'` and every call site would have silently built
`null/api/...`. A throw surfaces the misconfiguration instead of encoding it
into request URLs.

- restore the unconditional throw when NEXT_PUBLIC_APP_URL is unset or blank
- mirror it back in the shared testing mock
- flip the two fallback tests to assert the throw, keeping whitespace-only
  coverage

Server-side behavior is unchanged: there was never a `window` to fall back
to, so callers that already guard `getBaseUrl()` (`getBaseDomain`,
`validateCallbackUrl`) keep their existing fail-closed paths.
2026-08-03 12:52:24 -07:00
Waleed 09eba8a0f9 improvement(ci): move CodeQL off default setup onto Blacksmith (#6219)
* improvement(ci): move CodeQL off default setup onto Blacksmith

Default setup pinned every scan to a 4-vCPU GitHub-hosted runner with no
cancel-in-progress: PR scans ran 30-125 min and re-ran on every push (#6183
burned six overlapping runs). None of that is reachable from the settings UI,
so the config moves into the repo.

- Blacksmith 8-vCPU via the same CI_PROVIDER escape hatch as ci.yml
- cancel-in-progress scoped to pull_request so push/schedule scans finish
- push to main + PR to main/staging + nightly safety net
- paths filter so doc-only PRs skip the run entirely
- paths-ignore config drops tests/mocks/fixtures: 12,716 -> 11,128 files
- languages: javascript-typescript + actions; python dropped (7 files in tree)

Default setup has been disabled; the two cannot both be active.

* fix(ci): restore CodeQL coverage of the data-drain test route

Review round 1.

- Drop the '**/test/**' and '**/tests/**' globs. A `test` directory is a
  routable Next.js path segment, and those globs excluded
  apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts —
  a POST handler that authorizes, decrypts destination credentials and makes
  an outbound request. CodeQL paths-ignore has no `!` negation to carve it
  back out, and the globs only covered 76 of 12,716 files.
- Add `ready_for_review` to the pull_request activity types. It is not a
  default type, so a PR opened as a draft and later marked ready was skipped
  by the draft guard and never rescanned until the next push.
2026-08-03 12:50:58 -07:00
Waleed 83988c1ebb refactor(chat): drop code the voice removal left unreachable (#6218)
Full-file cleanup pass over the files voice mode was removed from.

- /api/speech/token: with the chatId branch gone, billingAttribution is
  always resolved, so four `billingAttribution ? ... : ...` guards and
  the checkActorUsageLimits fallback were unreachable. Removed, along
  with the now-unused imports and test mock.
- chat.tsx: the inputValue state had one remaining writer (`''`) and no
  reader — ChatInput has owned its own input value since it went
  uncontrolled, and removing the voice-transcript caller left
  handleSendMessage always receiving an explicit message.
- use-chat-streaming: messageIdMap was written in three frame handlers
  and never read, and setIsStreamingResponse was returned but never
  destructured by the only consumer.
- Comments: dropped JSX section labels that restate the element beneath
  them and TSDoc that restates the identifier; narrowed the speech
  contract's workspaceId doc, which existed to contrast with the
  removed chatId field.
2026-08-03 12:11:42 -07:00
Waleed 78740c05b6 chore(chat): remove deployed-chat voice mode (#6215)
Removes the voice-first interface and TTS playback from the deployed
chat, keeping workspace dictation, which is a separate feature.

- Deletes the VoiceInterface UI and its particles canvas, the chat mic
  input, the TTS audio-streaming hook, the /api/proxy/tts/stream relay
  and its contract, and the voice-settings query hook.
- Unpicks the voice wiring in chat.tsx and use-chat-streaming: the
  audio stream handler, sentence-splitting for speech, voice-first mode
  state, and the isVoiceInput plumbing through ChatInput.
- Drops the now-dead chatId branch from /api/speech/token. It was the
  anonymous public-chat path; with no caller left it would have stayed
  an unauthenticated relay spending the platform key. That leaves
  resolveDeployedChatCaller unused, so it goes too.
- Removes code the above orphaned: MAX_CHAT_SESSION_MS, the noop util,
  the audio/position refs in use-chat-streaming that were only ever
  written, and the chatId field on the speech contract.

Keeps /api/settings/voice, lib/speech and use-speech-to-text: the
workspace home input still uses them. Keeps the voice-output usage
source, enum and label — Postgres cannot drop an enum value, and
historical usage_log rows still need a label to render.
2026-08-03 12:05:43 -07:00
Waleed dd7951563e fix(env): restore beforeInteractive on the hosted public env script (#6214)
The hosted `<PublicEnvScript>` rendered a plain `<script>`, which lands at
the end of `<head>` — after the ~40 `<script async>` chunk tags Next emits
at the top of the document. An async script runs as soon as its fetch
resolves, so on a warm cache a Next chunk could execute (and hydration
begin) before the parser reached the env tag, leaving `window.__ENV`
undefined for the first render.

That surfaced as "Something went wrong" on the workflow page, since
`getBaseUrl()` throws during the deploy modal's render, and as the socket
falling back to the page origin instead of NEXT_PUBLIC_SOCKET_URL.

Regressed in #5522, which replaced next-runtime-env's PublicEnvScript
(to avoid its unstable_noStore forcing dynamic rendering) with a static
equivalent that dropped the beforeInteractive strategy.

- render the library's own `<EnvScript>`, which defaults to
  beforeInteractive and does not call unstable_noStore — hosted and
  self-hosted now share one implementation and one loading strategy
- drop the hand-rolled serialization and `<` escaping; Next's
  beforeInteractive path already runs the payload through
  htmlEscapeJsonString, which escapes `& > < U+2028 U+2029`
- fall back to the browser origin in getBaseUrl() rather than throwing,
  so a missing injected env can never tear down a page through the error
  boundary; server-side callers still fail loudly
- read NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED via getEnv() in the SSO
  form, matching login/signup/auth-modal — `env.X` returns the build-time
  placeholder, not the runtime value
2026-08-03 12:03:33 -07:00
Waleed 0bc4fb4656 fix(security): meter and throttle the deployed-chat TTS relay (#6212)
* fix(security): meter and throttle the deployed-chat TTS relay

POST /api/proxy/tts/stream treated "a live public chat exists" as
authorization to spend the platform ElevenLabs key. A public chat id is
handed to every visitor, so any anonymous caller could synthesize speech
with no length cap, no rate limit and no usage accounting.

Bring the relay in line with its STT sibling (/api/speech/token):

- Resolve the chat's workspace and bill synthesized characters to that
  payer via a new `voice-output` usage source, so spend is attributable
  and counts against the plan's usage limit (402 once exceeded).
- Throttle per IP before any database work, and per chat afterwards, to
  bound both one caller hammering many chats and many callers hammering
  one chat.
- Cap `text` at 2000 characters and allowlist `voiceId`/`modelId`, so the
  caller can no longer choose an unbounded charge, a premium or cloned
  voice, or the billing model.
- Drop `Access-Control-Allow-Origin: *`, which let any third-party page
  read the audio; deployed chat and the Office embed are same-origin.

* fix(security): correct TTS metering, pricing and body cap

Follow-up review of the previous commit found five defects in it:

- Usage rows collided. `usage_log.event_key` is unique and inserts are
  conflict-do-nothing, and the key is derived from the entry's stable
  fields. With no explicit sourceReference, two synthesis calls of equal
  character count in the same workspace produced the same key, so every
  repeat length went unbilled — defeating the metering this change is
  for. Each call now carries a unique sourceReference.
- Priced at $0.10 per 1k characters, twice the published ElevenLabs
  Flash/Turbo rate of $0.05, which would have overcharged customers 2x.
- No body cap, so an anonymous caller could make the route buffer up to
  the shared 50 MB default before validation. Now 16 KB, as the STT
  sibling does.
- Threshold settlement ran per sentence: several queries and a possible
  Stripe call on a realtime path. The workflow execution that produced
  the text already settles the payer.
- The per-IP bucket was described as preventing database amplification.
  getClientIp trusts the leftmost X-Forwarded-For, so an attacker rotates
  past it; the comment now says the per-chat bucket is load-bearing.

* refactor(chat): share the deployed-chat auth gate across voice routes

Review of the previous commits surfaced duplication and one more gap:

- The TTS and STT routes had grown near-identical copies of the chat
  auth + payer lookup. Extracted to resolveDeployedChatCaller, so the
  gate and the payer resolve together and cannot drift per route — that
  duplication is how the unmetered TTS path shipped in the first place.
- Neither copy filtered chat.archivedAt, so an archived chat could still
  authorize spend against its former owner's workspace. The shared
  lookup now filters it, fixing both routes at once. Note: not covered
  by a test — the db chain mock does not evaluate WHERE clauses, so an
  assertion here could not fail.
- Replaced the route's hand-rolled 429 builder with the existing
  enforceIpRateLimit helper, and added enforceChatRateLimit alongside
  the per-user/IP/workspace helpers. Gains the standard Retry-After and
  X-RateLimit-Reset headers plus throttle logging.
- Dropped a test that asserted a module the route no longer imports was
  never called: it could not fail.
- Narrowed the contract: unexported the single-use allowlists and
  dropped .passthrough() now that the body is a closed shape.

* fix(security): fail closed when voice-output usage cannot be recorded

Review round 1 findings:

- A ledger write failure previously logged and streamed the audio anyway,
  leaving the spend unrecorded and the payer's usage understated. The
  caller is anonymous, so serving audio we could not charge for is the
  unmetered spend this route exists to prevent — it now returns 500.
- Use generateId() from @sim/utils/id rather than crypto.randomUUID, per
  the AGENTS.md ID rule. generateId returns a full UUID v4, so the
  per-call uniqueness the usage_log event_key depends on is unchanged.

* fix(chat): split long TTS text so the relay cap cannot drop audio

The client sentence-splits on Western `.!?` only, so text that never
matches — CJK punctuation, or a list with no terminal punctuation —
accumulates and is flushed as one block at the end of the stream. Against
the new 2000-character relay cap that block is rejected and the whole
message plays no audio, a regression introduced by adding the cap.

Split to cap-sized pieces at the single point that enqueues synthesis, so
both the per-sentence path and the end-of-stream flush are covered.
Prefers a whitespace or CJK punctuation boundary, falling back to a hard
cut when a block has none. The server cap stays as the enforcement point.

* fix(security): release the vendor stream when metering rejects the request

The fail-closed branch returned 500 with the ElevenLabs response body
still open, so synthesis and download kept consuming vendor and runtime
resources for a caller that was already rejected. Cancel it before
returning, and assert the cancellation in the test.
2026-08-03 11:38:46 -07:00
Waleed 806ea0c5bb fix(tooltip): stop text blurring for ~150ms every time a tooltip appears (#6211)
* fix(tooltip): remove velocity skew/scale that blurred text on every appear

The tooltip animated a fractional scale() + skew() over 150ms on the element
containing its text. Chrome promotes the bubble to a compositor layer for the
transition, rasterizes the text once at the pre-transition scale, then
GPU-resamples that bitmap for the duration — so text rendered blurry until the
transition settled and the layer re-rasterized at 1:1.

It fired on every appear: a pointer entering a trigger is by definition moving,
so the first pointermove after pointerenter always set a non-zero skew and a
fractional scale.

- drop the velocity-reactive skew/scale flourish and the pointer-velocity
  bookkeeping that existed only to feed it
- round tooltip position to whole pixels; clientX/clientY are fractional on
  HiDPI/zoomed displays, leaving the bubble on a subpixel boundary
- drop the dead `filter` from the transition list — nothing ever set a filter
- skip the state update when the rounded position is unchanged, so pointer
  jitter no longer re-renders every Tooltip.Trigger/Content consumer

The 150ms ease-out translate is kept, so the bubble still trails the cursor.

* improvement(tooltip): keep the velocity flourish, drive it without a CSS transition

Restores the velocity-reactive skew/scale removed in the previous commit. The
flourish was never the problem on its own — handing it to a CSS transition was.
An interpolated fractional scale makes the compositor rasterize the tooltip's
text once and resample that bitmap for the duration, which is what read as blur.

Applied as a static value per pointer event instead, so every frame is
rasterized at its own scale:

- split the transform across the individual `translate`, `scale`, and
  `transform: skew()` properties, and transition only `translate` — position
  still eases toward the cursor, the flourish no longer interpolates
- smooth the pointer velocity in JS (low-pass filter) to replace the smoothing
  the CSS transition used to provide, so the squish still ramps rather than
  snapping between raw per-event velocities
- quantize the flourish to 3 decimals so jitter below the visible threshold
  settles instead of re-rendering every consumer

Whole-pixel position rounding and the redundant-update bail-out are unchanged.

* fix(tooltip): don't seed pointer velocity from the trigger box on focus

The previous commit routed `onFocus` through a shared reveal helper that seeds
`lastPointerRef` from the coordinates it is given. For focus those are the
trigger's box center, not the pointer — so if the pointer already happened to be
over the trigger, the next `pointermove` measured the box-to-cursor delta as
velocity and spiked the skew/scale flourish.

Split the helper in two: reveal-from-pointer seeds velocity tracking,
reveal-from-element leaves it cleared. Restores the pre-PR behavior, where focus
explicitly nulled the pointer snapshot.

Caught by Cursor Bugbot.

* fix(tooltip): make the flourish smoothing frame-rate independent

The velocity low-pass filter applied a fixed coefficient per pointer event, so
how fast the squish settled depended on how fast the device emitted events —
233ms at 30Hz down to 29ms at 240Hz, an 8x spread for the same gesture. It was
also far snappier than the 150ms CSS ease-out it replaced, so the flourish read
as twitchier than before.

Derive the coefficient from the real elapsed time instead
(1 - exp(-dt / tau), tau = 50ms). Settling is now flat at ~150ms from 60Hz
upward, matching the duration of the transition this stands in for.

Also separates the smoothing delta from the velocity-normalization delta: the
latter is still floored at one frame to keep a 1ms event from reporting an
enormous velocity, but flooring the former was itself a source of frame-rate
dependence below 16ms.

Verified against Chrome's documented re-raster behavior: a layer is re-rastered
at its new scale when the scale changes via script, but not when a declarative
animation interpolates it, which is why the flourish must stay out of the
transition list.
https://developer.chrome.com/blog/re-rastering-composite
2026-08-03 11:37:23 -07:00
Waleed 6806fc2f41 fix(resources): sort folders and resources as one list so pinned items reach the top (#6213)
* fix(resources): stop hoisting folders so pinned items reach the top

Folders and their sibling resources were sorted as two lists and
concatenated folders-first, so a pinned file, table, or knowledge base
could never rank above an unpinned folder — pinning only reordered
within each partition.

Sort them as one list instead: pinned -> sort key -> name, in a shared
`sortResources` comparator. Rows with no value for the active column
(a folder has no row count or token count) sort last in both
directions, and the name tiebreak is never inverted by `desc`.

Also aligns the surfaces that browse the same resources: the mothership
resource trees, the collapsed sidebar flyout (whose nested levels
partitioned folders-first while its own root interleaved), the sidebar
file tree, and the recently-deleted tiebreak.

Files' sort params were nullable only to encode "folders name/asc,
files updated/desc"; with one list that state is gone, so they default
to updated/desc like Tables and Knowledge.

* improvement(resources): prefetch pinned ids and members with the resource lists

Files, Tables, and Knowledge already prefetched their items and folders,
but not the two lists a complete row needs.

Pinned ids are the list's primary sort key now, so a page that painted
before they arrived rendered the whole list in the wrong order and then
visibly re-sorted. Members back the Owner column, which painted empty
and filled in after.

Both now hydrate alongside the lists via `prefetchResourceListChrome`.
Pinned keys move to `hooks/queries/utils/pinned-item-keys` so the server
prefetch can address them without pulling the contracts barrel and the
optimistic-mutation machinery into the route.

* chore(knowledge): drop the dead pre-redesign knowledge base sort

`sortKnowledgeBases` was a second, complete ordering for knowledge bases
(name/createdAt/updatedAt/docCount) left over from the old sort dropdown,
with no consumers since the shared Resource sort menu replaced it — the
kind of duplicate that silently diverges from the page it shadows.

Its `SortOption`/`SortOrder` types and the `SORT_OPTIONS` list that fed
it were dead with it. `utils/sort.ts` now holds only a filter, so it is
renamed to `utils/filter.ts`.

* fix(resources): sort unknown owners last and align the knowledge sort menu

An owner id that resolves to no workspace member renders an empty cell,
but its sort key was `''`, so those rows floated to the TOP of an
ascending Owner sort while every other valueless cell sorted last. They
now key `null` and follow the same nulls-last rule on all three pages.

The Knowledge sort menu also listed Owner after Last Updated while its
column order — and Tables' menu — put Owner before it.

* chore(knowledge): delete the dead base-card grid view

`BaseCard`/`BaseCardSkeleton`/`BaseCardSkeletonGrid` were the pre-redesign
grid-card knowledge base view, kept alive only by the barrel re-export —
no call sites since the Resource list replaced it.

Removing it orphans `components/constants.ts` entirely (its sort types
went with the dead sort; its three class-name constants had no consumers
left), so that goes too.
2026-08-03 11:32:44 -07:00
Waleed feaddc437a improvement(headers): one action cluster and one ordering app-wide (#6210)
Extends the settings-header ordering past detail pages.

Five headers hand-rolled their own action wrapper. `resource-header` used
`flex shrink-0 items-center` — no height, no gap, so chips on tables, files,
knowledge, logs and scheduled tasks sat flush against each other; the
integrations tab strip and the integration block detail each used `ml-auto flex
items-center`. Only the settings shell and credential detail wore the intended
`flex h-[30px] items-center gap-1`. That string is now HEADER_ACTION_CLUSTER,
next to PAGE_HEADER_BAR, and all five compose it.

`Resource.Header` now ranks its actions through orderHeaderActions too, so the
resource pages inherit the same order as settings rather than rendering their
array verbatim. `ResourceAction` gains `id`, which was the only field keeping it
from being a subset of `SettingsAction`.

Delete is now ranked by its `id` rather than by where the caller put it. That
matters for a header with no primary action: the file detail listed
`Download → Share → Delete`, leaving a destructive chip in the slot a primary
would occupy. Tagging it `id:'delete'` fixes that without inventing a primary.
2026-08-03 10:31:52 -07:00
Waleed b741176d4b improvement(settings): one header action order across detail pages (#6206)
Detail headers disagreed on where Delete sits. The skills page reads
`Share → Delete → Discard → Save`, but every SettingsPanel page spread
saveDiscardActions() first and appended Delete, rendering it to the RIGHT of
the primary chip: sandboxes, custom tools, custom blocks, permission groups and
data retention all did this.

Fixed in the shell rather than at nine callsites. orderHeaderActions() ranks
actions — secondary, then `id:'discard'`, then `variant:'primary'` — stably
within each band, so writing the array the natural way now produces the right
header and a new detail page cannot get it wrong. This generalizes past Save:
workflow MCP servers' `Add workflows` primary is now right-most with Delete
before it, instead of the reverse.

The ranking has to survive one trap. The shell routes onSelect through
configRef.current.actions[index] to avoid stale closures, so reordering the
render without preserving the source index would bind every chip to the wrong
handler — clicking Delete would Save. orderHeaderActions carries {action,index}
pairs; settings-header-shell.test.tsx pins that at the render level, including
the conditional-Discard case where a missing action shifts every index.

Delete is also now a plain chip on the nine resource-detail headers, matching
skills, each with a stable `id:'delete'` (three lacked one, so the chip
remounted when its label flipped to Deleting...). `variant:'destructive'` is
kept for actions destructive at scale — Delete all passwords, Clear all
browsing data, Sign out all members — which the confirm modal does not cover
the way it covers removing the single resource you are looking at.
2026-08-03 10:17:04 -07:00
03333ce30d feat(library): AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean (#6204)
* feat(library): AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean

* feat(library): add generated cover for AEO vs GEO post

---------

Co-authored-by: Sim Pi Agent <pi@sim.ai>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-03 09:44:37 -07:00
Vikhyath Mondreti f07160aec7 fix(trigger): deps to include e2b and daytona (#6205) 2026-08-03 09:39:04 -07:00
Waleed 030c4e2a5e refactor(auth): extract connector definitions out of auth.ts (#6203)
auth.ts had grown to 3,927 lines, of which ~2,340 were the genericOAuth
connector list — the OAuth apps a workspace connects tools to, as distinct from
the handful of providers used to sign in to Sim. Adding a connector meant
editing the same file that configures sessions, database hooks and Stripe.

Moves that list to lib/auth/connectors/providers.ts behind
buildConnectorProviders(), and relocates getMicrosoftUserInfoFromIdToken to
lib/oauth/microsoft.ts alongside the three Microsoft helpers it already depends
on. auth.ts drops to 1,489 lines and reads as auth configuration again.

Pure move, verified mechanically: the connector array is token-identical after
stripping whitespace, and all 179 template literals emit byte-identical strings
(the one apparent diff was reindentation inside a ${} expression, not text).
Behavior, evaluation order and log scopes are unchanged; the array is still
built once, when betterAuth() runs.

The explicit GenericOAuthConfig[] return type is required, not cosmetic —
inline, the entries were contextually typed by the config property. Without it
prompt: 'consent' widens to string and every getUserInfo parameter becomes
implicitly any.
2026-08-03 09:35:17 -07:00