Commit Graph
5976 Commits
Author SHA1 Message Date
Waleed cb63ecaefb fix(inbox): disable the inbox atomically and simplify its webhook tests (#6441)
disableInbox deleted the webhook row and cleared the workspace columns as
two independent statements. Now that inbox_provider_id is uniquely indexed,
a half-applied disable strands the id of an AgentMail inbox that no longer
exists, and the next workspace to claim that address cannot enable at all.
Wrap both writes in one transaction.

The receiver's tests also hand-rolled a table fixture that schemaMock already
provides and queued rows the shared mock returns by default. Drop both, and
assert the routed inbox id on the unknown-inbox case so it fails against a
revert.
2026-08-08 13:42:41 -07:00
Waleed 77bc8badf0 test(triggers): guard the provider granularity the path route depends on (#6437)
* test(triggers): guard the provider granularity the path route depends on

The public trigger route classifies delivery by provider, not by trigger
id, so a provider owning both a polling and an HTTP trigger would have its
HTTP deliveries rejected. Sim already models dual-delivery services as two
providers (slack / slack_app), but nothing asserted it: the existing
POLLING_PROVIDERS sync assertions all still pass for a mixed provider.

* test(triggers): exempt provider-gated triggers from the granularity check

Classifying every non-polling trigger as path-delivered was too broad:
internal and app-level-ingress providers never serve the public path
route either, so mixing polling with those would have failed the
invariant despite there being no routing conflict to fix.
2026-08-08 13:42:29 -07:00
Waleed 0b016c26dd fix(webhooks): drop the AgentMail-specific webhook body cap (#6436)
The receiver had its own 4 MB cap on the grounds that it is the only one
that parses an unverified body before authenticating. It is not — the
generic path receiver parses at the shared cap well before verifying auth,
so the tighter limit was inconsistent rather than principled, and it could
reject a large-but-legitimate email outright. The one-signature-check-per
-request property comes from the tenant lookup and its unique index, not
from the cap.

Also records why the routing id is bounded at 320 characters.
2026-08-08 13:33:31 -07:00
Waleed a7b016b655 fix(files): make the rich-markdown-field container a positioning context (#6435)
rich-markdown-field also mounts EditorBubbleMenu with its own container ref, so
the same 'relative' the editor container needs applies here — otherwise the
absolutely-positioned toolbar resolves against the wrong offset parent and scroll
tracking breaks in the field. Safe in both modes: 'relative' adds no offset and no
clipping to the uncapped (page-scrolls) box.
2026-08-08 13:29:22 -07:00
Waleed 33fe043b6b fix(cli): generate per-install secrets instead of using fixed values (#6433)
* fix(cli): generate per-install secrets instead of using fixed values

The launcher passed the same built-in BETTER_AUTH_SECRET and ENCRYPTION_KEY
to every install. Generate them once per install, persist them 0600 at
~/.simstudio/secrets.env, and reuse them on later runs so data already in
the Postgres volume stays readable.

Also passes INTERNAL_API_SECRET, which the realtime container requires and
never received.

* fix(cli): reassert owner-only permissions on the secrets file

writeFileSync's `mode` applies only when it creates the file, and the write
is skipped entirely when the stored values are already valid — so a secrets
file left with permissive permissions kept them. chmod it on every run.

* fix(cli): write the secrets file atomically

Regenerating any one key rewrites all of them, and a plain write truncates
first — a crash mid-rewrite would strand a still-valid ENCRYPTION_KEY and
orphan the data it protects. Write to a temp file and rename into place.
2026-08-08 13:22:30 -07:00
Waleed 9f50508248 fix(files): pin editor bubble menus to the cursor during scroll (#6434)
The text and table toolbars were positioned `fixed` and portaled to `<body>`, so TipTap
repositioned them on a debounced scroll listener — the toolbar visibly lagged and "chased"
the cursor as the pane scrolled.

Leave the menus at TipTap's default `absolute` strategy and append them into the editor's
scroll container (now a positioning context) so they become absolutely-positioned children
of the scrolled content and track the selection through native scrolling — no scroll
listener, so no lag. TipTap's default `flip`/`shift` keep the toolbar above/below the
selection at the pane edges, and the container's overflow clips it once the selection
scrolls out of view.

Verified in a harness: 0px menu-to-cell drift during scroll (was 100s of px on fast
scroll), and no clipping at the top, bottom, or table edges.
2026-08-08 13:15:58 -07:00
Waleed ea5df41a57 fix(webhooks): resolve the AgentMail tenant before verifying the signature (#6431)
* fix(webhooks): resolve the AgentMail tenant before verifying the signature

The receiver identified the delivering workspace by trying every stored
webhook secret in turn, so each request cost one HMAC over the full body
per tenant. Resolve the workspace from the envelope's inbox id first and
check a single secret, and make that id uniquely indexed so the one-check
property holds by construction.

Also gates the route through the shared admission gate, like the other
public receivers, and gives it a body cap sized for an email envelope.

* fix(db): declare the inbox provider index partial in the schema too

The migration built a partial unique index but the Drizzle definition and
snapshot omitted the predicate, so a regenerate would have diverged. Also
records at the registration site why only message-bearing event types can
be subscribed.

* fix(db): drop the duplicate 0285 journal entry

The regenerate that added the partial-index predicate appended a second
entry for the same migration, so drizzle would have replayed it — dropping
and rebuilding the index, leaving a window with no unique constraint.
2026-08-08 12:59:45 -07:00
Waleed 5a9f64a273 fix(files): bound HTML parser input before building the DOM (#6423)
* fix(files): bound HTML parser input before building the DOM

Rejects HTML documents above a byte and markup-token budget before cheerio
builds the document tree, and wires the rejection into the parse route's
fail-closed path alongside the existing YAML one.

* fix(files): classify HTML extraction RangeErrors as complexity rejections

* fix(files): scope the parseFile try to the read so typed rejections propagate

* fix(desktop): mock electron in tests that transitively import it

url-guard, csp, and telemetry-policy all reach electron through
@/main/navigation but never mocked it, so they depend on a working
Electron binary download and fail when that install is incomplete.
2026-08-08 12:45:16 -07:00
Waleed 87b7b4b685 improvement(files): harden untrusted document preview and parsing (#6420)
* improvement(files): harden the docx preview renderer

* fix(files): tighten the OOXML archive size guard against parser OOM

* fix(files): apply the per-entry OOXML cap to every part, not just XML names
2026-08-08 12:42:46 -07:00
Waleed a2ad4b693a fix(files): escape user filenames in the Content-Disposition header (#6430)
The download name is the user's originalName, which only rejects path
separators, so a quote reached the quoted filename parameter unescaped and could
close it and append parameters of its own. An injected filename* is the one that
matters: RFC 6266 tells clients to prefer it, so it decides the name the file
lands under on disk regardless of what the UI showed.

- neutralize the quote, backslash and non-printable characters in the quoted
  parameter, and neutralize the semicolon there too since that fallback exists
  for clients liable to split parameters without honouring the quoting
- percent-encode the filename* ext-value fully, including the characters
  encodeURIComponent leaves raw — the apostrophe is the ext-value delimiter
- names that are already safe printable ASCII keep their exact previous header

Also stops a control character in a name from producing an invalid header value,
which previously made the download 500.
2026-08-08 12:33:39 -07:00
Waleed 05aabc3be1 fix(mcp): apply the workspace personal API key setting to MCP serve auth (#6429) 2026-08-08 12:25:40 -07:00
Waleed 1d67e01ee4 fix(webhooks): centralize the path-delivery rule for trigger providers (#6428)
Fold the internal- and polling-provider exclusions into
acceptsPathWebhookDelivery so the generic per-webhook path route has one
predicate deciding which providers it serves, instead of the route body
re-deriving it. Widen isPollingWebhookProvider to accept a nullable
provider, matching isInternalTriggerProvider, and drop the resulting
`?? ''` at both call sites.

Regression coverage is sourced from the trigger registries so a newly
added internal or polling trigger is covered automatically.
2026-08-08 12:25:30 -07:00
Waleed 441004ad24 improvement(file-parsers): bound PDF text extraction (#6425)
* improvement(file-parsers): bound PDF text extraction

Extract page text through pdf.js's streaming API with page, character, and
wall-clock budgets instead of buffering the whole document, so extraction
memory stays bounded regardless of input. Release the document proxy when
done, and route output through sanitizeTextForUTF8 like the other parsers.

* fix(file-parsers): only flag truncation when PDF text is actually dropped
2026-08-08 12:21:07 -07:00
Waleed 52be28eb3a improvement(execute): enforce workspace permissions on function file exports (#6427)
Check the acting user's workspace access before a function execution uses a
request-supplied workspaceId, and gate workspace file writes in the shared VFS
writer so every caller is covered by default.

Access is resolved once per request and threaded through the export path so the
added check does not re-query per output file.
2026-08-08 12:16:59 -07:00
Waleed 3726bd22ca fix(knowledge): apply knowledge-base access checks consistently across auth types (#6426)
* fix(knowledge): apply knowledge-base access checks consistently across auth types

The tag-definitions route only ran its knowledge-base access check for browser
sessions, skipping it for internal JWT callers. Authorize on the acting user
for every auth type instead — read access for GET, write access for POST — and
require an acting user to be present, matching the sibling knowledge routes.

Thread the acting user through the KB tag schema enrichers so their request
carries the identity the route now authorizes.

* chore(tests): mark tag-definition test fixtures as const
2026-08-08 12:14:35 -07:00
Waleed 9f8368e9f4 fix(files): anchor the editor bubble menus to the selection on scroll (#6419)
The text and table bubble menus stayed pinned to a viewport position when the file
scrolled — clicking a table cell then scrolling left the toolbar floating over unrelated
content. TipTap v3's BubbleMenu reposition listener defaults to `window`, but the editor
scrolls inside an inner overflow container, so it never fired; the menu only moved when
the selection itself changed.

Pass the editor's scroll container as the BubbleMenu `scrollTarget` (a first-class TipTap
option) so it repositions with the selection, and enable Floating UI's `hide` middleware
so the menu hides once its anchored cell scrolls out of view. Share the anchor + options
through one `floating-anchor` helper so the two menus can't drift.

Removes the prior workarounds that fought this: the `strategy: 'fixed'` viewport-pin, the
resolveAnchor viewport-clamp branches, and the bubble menu's selection-keyed rect cache
(which froze the menu in place on scroll).

Verified in a harness: on scroll the menu delta matches the cell delta (follows), and it
hides once the cell leaves view.
2026-08-08 12:12:40 -07:00
Waleed 533afaaedc fix(docs): stop the pinned sidebar running under the site footer (#6422)
The sidebar and its divider are fixed to the viewport, so at the end of the
page the footer was drawn over them and the lower part of the nav list became
unreachable.

FooterOverlapProbe publishes how far the footer reaches into the viewport as
`--docs-footer-overlap`. The sidebar reads it as `bottom`, so it keeps its full
height and slides up out of view as the footer arrives; the divider reads it too
but is shortened rather than slid, so it terminates on the footer's top border
instead of stopping short.

Measured against the viewport rather than the document on purpose: the value is
a constant 0 while the footer is off screen, so a content-height change higher up
the page cannot move the sidebar. Verified with Playwright at 1280x800 and
2000x1100 — expanding/collapsing an FAQ with the footer off screen moves the
sidebar 0px/0px and leaves the content column unchanged, and at the page bottom
the sidebar's bottom edge lands within ~1px of the footer's top.
2026-08-08 12:10:31 -07:00
Vikhyath Mondreti 0aae736b07 fix(provenance): stop short secret values from rewriting unrelated log text (#6416) 2026-08-08 12:09:37 -07:00
Waleed 4f5ad2011f fix(tables): keep row context menu labels on one line (#6418)
* fix(tables): keep row context menu labels on one line

* improvement(emcn): ellipsize menu row labels instead of clipping them
2026-08-08 11:55:17 -07:00
Justin Blumencranz 6c6d8a5756 improvement(chat): rename "Branch in new chat" to "Fork in new chat" (#6417) 2026-08-08 11:11:52 -07:00
Waleed 8a224be397 fix(files): read the Files prefetch from the data layer and bound invitation previews (#6415)
Two unrelated load-time fixes.

On a hard refresh of /files the folders painted first and the files a beat
later. Both are prefetched and hydrated together, so the files entry was not
reaching the client. Each read went to its own route over an internal HTTP
request; prefetchQuery swallows a rejection and shouldDehydrateQuery drops the
errored entry, so a failure there silently shipped a page with that list
missing, and the files read is the heavier of the two. Those two reads now call
the data layer. Note the staging logs show no errors from that route, so this
removes the failure mode without proving it was the one firing — the request it
drops from the render path, and the shape fix below, stand on their own.

listWorkspaceFilesWithShares is shared by the route and the prefetch and shapes
its result through the route contract's response schema. listWorkspaceFiles
returns contentUpdatedAt, which the schema neither declares nor passes through,
so the prefetch was caching a field a client fetch never has and that vanished
on the next refetch.

The reads carry no authorization of their own now that they bypass the route,
so the prefetch proves the viewer first. It reuses the layout's cached
host-context lookup rather than re-deriving the permission, so the gate costs no
extra queries.

Separately, GET /api/invitations computed join previews in a serial loop and
each preview issues up to three queries of its own, putting all of them on the
critical path of the workspace switcher opening. Bounded with
mapWithConcurrency; the mapper was already total, which is what that helper
requires.
2026-08-08 11:10:49 -07:00
Vikhyath Mondreti 3b0651ed9c feat(library): Best AI Agents for Customer Support Ticket Triage and Routing (#6408) 2026-08-08 00:41:07 -07:00
Justin BlumencranzandWaleed Latif 8c6166e149 fix(mship): return the chat connect flow to the tab that started it (#6403)
* fix(mship): return the chat connect flow to the tab that started it

Connecting an integration from a chat credential chip opened OAuth in a
new tab and returned there, so the user landed on a second copy of the
app while the conversation they started from sat stale behind it.

The flow now runs in a popup and returns through a new self-closing page
at /oauth/chat-complete, which publishes its verdict to the shared
attempt record and closes. The chat tab picks that up over its storage
listener and updates in place, so it never navigates. A blocked popup
takes the same route in a new tab and still lands on the completion
page, so both paths share one verdict source.

That verdict is now the server's: reaching the completion page means
Better Auth routed the flow to its success callback. The previous check
diffed the workspace credential list, which reported failure whenever a
user re-authorized an account they had already linked -- that path
updates the account row and creates no new credential.

The lock is stricter than the label. A failure to create the credential
from its draft is swallowed server-side, so a flow can report success
with nothing in the workspace; the row stays retryable unless the
credential actually appears.

Also: the popup is named per attempt so sibling rows cannot renavigate
each other's window; a cross-origin connect URL keeps the anchor's
noopener instead of taking the popup path; the focus verifier reads the
attempt after its refetch so a verdict published mid-flight is not
overwritten; and the verifier treats a popup parked on a terminal page
(/oauth-error, the workspace error exit) as finished rather than
waiting on it forever.

* fix(mship): settle the connect row from the popup, not from focus alone

Addresses the review findings on the chat OAuth return leg.

- Watch the popup on an interval. A provider interstitial bouncing to the
  workspace root, a denied consent on /oauth-error, or a closed window all end
  the flow without publishing a verdict or firing any event in this tab, so the
  row waited forever. The focus handler also no longer consumes the away flag
  when it defers to a live popup.
- Focus an already-running popup on a repeat click instead of starting a rival
  attempt, which orphaned the first flow's verdict on an attempt id the row had
  stopped reading.
- Settle from the refetched credentials on the popup success path, so the row's
  lock is corroborated and a connected row stops being clickable.

Extracts the shared refetch-then-decide step into settleFromCredentials, used
by the focus handler, the popup watcher, and the success path.

* fix(mship): never read a disowned popup handle as a finished flow

A provider page with COOP same-origin disowns the popup, and the disowned
handle reports closed for a consent screen still running. The watcher took
that as an ending and published 'failed' against a live flow.

- Replace the boolean with a three-state observation. Only a same-origin
  terminal page counts as 'ended'; a closed-or-disowned handle is
  'unobservable' and publishes no verdict. Closing a popup hands focus back
  to this tab anyway, so the focus verification settles that case.
- Stop the interval before settling. The refetch leaves the status pending
  for its duration, so a running interval could fire again and resolve an
  attempt a retry had since replaced.
- Gate the success toast on a launched-attempt ref rather than the window
  handle, which the watcher clears before React applies the verdict.

* fix(oauth): keep the chat connect return leg in its opener's browsing context

/oauth/chat-complete runs as a popup but fell into the strict COOP rule, so
same-origin moved it into its own browsing-context group the moment it loaded
— disowning it from the tab that opened it. That is the documented cause of a
popup that is not reliably script-closable and whose opener sees window.closed
report true for a live window.

Matches it to its opener's same-origin-allow-popups instead, which is the
directive the platform provides for exactly this case.

* fix(oauth): keep every page an OAuth popup lands on observable to its opener

The popup watcher settles on a same-origin terminal page, but both entries in
OAUTH_POPUP_TERMINAL_PATHS were served strict same-origin COOP, which moves the
popup into its own browsing-context group. The opener could then neither read
its location nor trust window.closed, so the terminal-page branch could never
fire in production and a flow exiting through one of those pages left the row
waiting until the user happened to refocus the tab.

Serves /oauth-error and the /workspace root the same same-origin-allow-popups
their opener uses. The workspace root previously fell under the strict rule
while every /workspace/... route already got the permissive one.

* test(mship): cover the announcement surviving an early popup release

The success toast is gated on the launched-attempt ref rather than the window
handle; nothing pinned that. Adds the regression test, and trims the comment
duplication the fix left behind.

* fix(mship): bound the wait on a popup whose outcome became unobservable

A closed handle and a COOP-disowned one are indistinguishable, so the watcher
published no verdict for either and relied on the focus verification to settle
it. That recovers the normal case — closing a popup hands focus back — but not
one where the opener was never blurred, leaving the row waiting indefinitely.

Arms the same safety timeout the MCP OAuth popup uses for the same reason: past
it, the row decides from the credential list rather than waiting on a verdict
that is never going to arrive. Cleared as soon as a real verdict lands.

* fix(mship): survive a remount while an attempt is still pending

The unobservable deadline lived in the watcher effect's closure, so it was
armed only by the mount that launched the popup. The transcript virtualizes:
a row scrolled away mid-connect came back with no window handle and no blur
behind it, and nothing re-armed the bound.

Derives the deadline from the attempt's own requestedAt and arms it for any
pending attempt, so a remount inherits the time remaining rather than
restarting the clock or losing it. A demonstrably live popup still owns the
flow and is left to the watcher.

* fix(mship): bind a settle to its own attempt and keep the deadline armed

Two races the previous rounds left behind.

A settle read the attempt only after its refetch, so a retry landing during
that window was resolved by a run it never triggered — failing a replacement
whose popup was still going. The attempt id is now captured before the await
and the verdict only lands if it still matches; the status is still re-read
after, so a verdict published mid-refetch is not overwritten.

The safety deadline was one-shot. A consent screen that outlived it consumed
the timeout while still live, leaving nothing to catch the popup dying
unobservably afterwards. It now re-checks at the poll interval instead of
expiring against a live window.

* chore(mship): tighten the comments on the OAuth popup flow

Trims the COOP rationale in next.config.ts to the point, and condenses the
longest blocks in the connect hook without dropping the reasoning a reader
needs to keep the invariants.

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-07 23:43:35 -07:00
Waleed b5e5ca535a improvement(ci): run the CodeQL cron weekly and cancel superseded scans (#6406) 2026-08-07 22:04:23 -07:00
Waleed cb8338c424 fix(workspaces): give the pin and options button one shared slot (#6402)
The pin sat inline before an always-reserved 18px options button, so a pinned
row's name lost ~18px of truncation budget — pinning visibly re-truncated the
name at the moment of the click, and hovering showed pin and options together.

Match the chat rows: one fixed 18px slot with both absolutely positioned, the
pin fading out as the button fades in. The trailing width is now constant, so
pinning cannot reflow the name. The options glyph moves to --text-icon, the
canonical icon token its new sibling already uses.
2026-08-07 20:51:15 -07:00
Vikhyath Mondreti 457170b7bf fix(agent): overly broad check for secrets protection (#6399)
* fix(agent): overly broad check for secrets protection

* remove opaque input processing

* fix

* address comments

* fix
2026-08-07 20:25:16 -07:00
Waleed 08af7db910 improvement(ui): unbold the app, align the folder chevron, tidy the feedback modal (#6400)
#6241 deleted the --font-weight-* scale from globals.css and its fontWeight
mapping from tailwind.config.ts. font-medium jumped 440/480 -> 500,
font-semibold 500/550 -> 600, and body dropped 420 -> 400, so every existing
call site snapped a full step above a body that got lighter. #6291 fixed
packages/emcn only; the product call sites were left behind.

Strips the weight class from body, label, row, and heading text across
app/workspace, ee, workflow-renderer, the non-workspace route groups, and
components/ui/button.tsx, whose buttonVariants injected font-medium into every
consumer. Keeps it only where it steps up: markdown/prose bold and micro
avatar initials.

Also aligns the workflow-tree folder chevron to the sidebar section header
(14px, 150ms), and on the feedback modal drops the prompt line above the
Feedback field and re-homes Copy ID as a footer secondary action.
2026-08-07 20:12:06 -07:00
Waleed 3b4d587b55 feat(demo): fire the X conversion event when a demo is booked (#6401) 2026-08-07 20:09:49 -07:00
Waleed 1c5393ad6d feat(workspaces): pin workspaces and widen the switcher to six rows (#6397)
* feat(workspaces): pin workspaces and widen the switcher to six rows

Show up to six workspaces in the switcher instead of three, keeping the
search input from six onward so it appears exactly when the list fills.

Pin workspaces to the top of the switcher via the existing row context
menu. Pins are per-user and global, so they live on the user's settings
row rather than in `pinned_item`, which scopes every row to one
workspace. They ride along on the /api/workspaces payload the switcher
already loads, so the server prefetch hydrates them and pinned-first
ordering never re-sorts after hydration.

Drop the seat/workspace-migration disclosure copy from both invitation
accept surfaces. The accept-time disclosure tokens are unchanged, so the
server still verifies the outcome hasn't shifted since the page loaded.

* fix(workspaces): serialize pin writes so a rapid toggle cannot be undone

Each write carries the whole pin list, so two overlapping requests that the
network delivered out of order left the earlier click as the stored state.
Chain them instead, and hold reconciliation until the last queued write
settles — refetching between two writes rendered the server's intermediate
state and bounced the row out of the pinned group and back.

* refactor(workspaces): store workspace pins in pinned_item, not user settings

Workspace pins were a jsonb array on the settings row, replaced wholesale on
every toggle. That shape is what forced the write serialization in 53ee94f5:
two overlapping toggles each sent the entire list, so the one that landed last
won regardless of which the user clicked last.

pinned_item is the canonical pinning table and its resource_type is plain text
precisely so kinds can be added without a migration, so `workspace` joins it as
a sixth kind. A pin is now one row: pinning inserts, unpinning deletes, and two
toggles touch different rows and cannot overwrite each other. The serialization,
the outstanding-write counter, the settings column, and its migration all go
away, and deleting a workspace now cascades its pins.

Reads stay on the /api/workspaces payload — the switcher needs the pins *of*
every workspace, not the pins *inside* one — so the sidebar prefetch still
hydrates them and pinned-first ordering is correct on first paint.

* fix(workspaces): serialize same-workspace pin toggles and tolerate replays

Splitting pins into rows removed the lost-update race between *different*
workspaces but not the one on a single row: pin then unpin the same workspace
and the DELETE could overtake its INSERT, delete nothing, and leave the
workspace pinned. A mutation scope serializes them; TanStack runs onMutate
before the scope gate, so the optimistic update is still immediate.

Both duplicate-click replays now resolve to their end state rather than
erroring — a repeat pin answers 409, a repeat unpin 404, and each means the
row is already how the caller wants it. Rollback undoes its own toggle instead
of restoring a snapshot, so a sibling toggle's optimistic state survives.

Also: cap the switcher to the height Radix measured, since six rows can push
the footer actions off a short viewport with nothing able to scroll to them;
drop a dead pinned-item invalidation and a redundant ref; return the pin set
from the hook to match usePinnedIds; and exclude workspace pins from the
unscoped pinned-items listing, where they would read as a resource inside
themselves.

* fix(workspaces): hold pin reconciliation until nothing is still queued

The mutation scope serializes the writes, so an earlier toggle settles while a
later one is still waiting its turn. Invalidating there refetched the server's
intermediate state and bounced the row out of the pinned group and back before
the last write had even left the client.

* fix(workspaces): count outstanding pin toggles off the mutation cache

`hooks/queries/workspace.ts` has no 'use client' directive because server code
imports `workspaceKeys` during SSR, so the `useRef` counter added in 3dc924da
broke the production build — caught by CI, not by typecheck or tests.

`isMutating` answers the same question without a hook: `onSettled` runs before
the mutation leaves `pending`, so it counts itself, and anything above one means
a later toggle is still queued behind the scope.
2026-08-07 20:00:12 -07:00
Waleed d317607d2c feat(dynatrace): add the write and configuration surfaces (#6398)
* feat(dynatrace): add the write and configuration surfaces

Takes the block from 22 operations to 47. The original PR shipped the read
paths plus a few ingests; this closes the gaps that made those reads
dead-end.

The one that was a real defect: security was read-only. The audit-vulnerabilities
skill promised "a remediation queue" and then gave you no way to act on it,
even though muting is the single most common triage action. Adds mute and
unmute, singly and in bulk, plus the remediation items behind a third-party
finding, plus the Attacks API so an exploited vulnerability can be traced to
the request that exploited it.

The rest, by how much they unblock:

- Custom tags (read/add/delete). Entity tags already drive every selector in
  the block; being able to write them closes a loop that was half open.
- Settings objects (schemas, list, get, create, update, delete). This is how
  maintenance windows, alerting profiles, and management zones are configured
  in modern Dynatrace, so "open a maintenance window before the deploy" was
  simply unreachable before. The value is a schema-defined blob, so the tool
  is honestly opaque rather than falsely typed; the docs tell you to mirror an
  existing object. Update and delete carry the updateToken so a concurrent
  change fails instead of being overwritten.
- Synthetic monitors and on-demand batch execution, which pairs with the
  deploy-marker tool to gate a release on a smoke test.
- Problem comment get/update/delete, and SLO create/update/delete, completing
  CRUD that was previously half-built.

Two structural notes. Synthetic monitors are the only endpoints still on
Environment API v1, so `buildDynatraceUrl` grew a v1 sibling and the shared
base-URL normalizer now strips either version; the query builder also learned
to repeat a param per value, which Synthetic's `tag` needs. And creating an
SLO returns 201 with an empty body and the new ID in the Location header, so
that tool reads the header rather than parsing nothing.

Deliberately excluded: the Grail/DQL query API. It is the long-term successor
to the deprecated logs/search endpoint, but it authenticates with a platform
token rather than an Api-Token, so it is a second auth path and belongs in
its own change.

* fix(dynatrace): drill the documented JSON shapes, and require the tag selector

Two problems, one found in review and one worth more than it was given.

The tag operations could run without an entity selector. All three tag tools
declare `entitySelector` required, but the shared block field was only marked
required for List Entities, so the block let a workflow reach those tools with
an invalid configuration and let Dynatrace do the rejecting. My own structural
auditor missed it because it only checked that *some* visible subBlock existed
for a required param, not that the specific one was required — that check is
now precise, and it confirms these three were the only instances across all 47
operations.

The larger one: outputs were declaring `type: 'json'` for shapes the API
reference documents in full. Thirty-five of them. The top-level entities were
mapped properly, but nested payloads — a problem's evidence and impact
analysis, a vulnerability's risk assessment and global counts, an attack's
attacker, request, entry point and exploited vulnerability, a remediation
item's assessment and mute state, the synthetic execution and failure records,
the metric ingest error envelope, the DQL translation — were passed through as
anonymous blobs. A downstream block could not reference `attacker.sourceIp`
without knowing to guess it. All of those now carry their fields.

What stays opaque is now only what genuinely is, and each says why in its
description: a settings object's schema-defined value, an entity's
type-dependent property bag and relationship keys, caller-supplied synthetic
metadata, an audit log's JSON patch, the undocumented partial-success body of
log ingestion, and the handful of security-detail shapes the reference names
without expanding.

* fix(dynatrace): make the synthetic enabled filter tri-state

Review catch. `enabled` on List Synthetic Monitors is a three-way filter —
enabled, disabled, or either — and I had it as a switch. The URL builder
deliberately serializes `false` (there is a test pinning that `evaluate=false`
survives), so leaving "Enabled Only" unchecked sent `enabled=false` and
returned only the disabled monitors: exactly backwards.

Made it a dropdown with Any / Enabled only / Disabled only, matching the
monitorType field directly above it, which had the same shape and already used
an empty-id "Any" option. The params mapper sends nothing for "Any".

Checked the other nine switches rather than assuming. None share the bug: for
each of them off genuinely means false, and false is Dynatrace's own default,
so serializing it is correct. A test now pins that list so the trap cannot be
re-introduced by converting one of them, alongside a test covering all three
states of the filter.
2026-08-07 19:39:55 -07:00
Waleed 2e7e5ae804 feat(dynatrace): add the Dynatrace integration (#6393)
* feat(dynatrace): add the Dynatrace integration

Adds a Dynatrace block backed by 22 Environment API v2 tools, covering the
surfaces an observability workflow actually reaches for:

- Problems: list, get, close, list comments, add comment
- Metrics: query data points, list and get descriptors, ingest line protocol
- Entities: list, get, list entity types
- Events: list, get, ingest
- Logs: search, ingest
- SLOs: list, get
- Application Security: list and get security problems
- Audit log: read

Every request path, query parameter, and response mapping is taken from the
published Dynatrace API reference — no inferred fields. Auth is an access
token sent as `Authorization: Api-Token ...` against a user-supplied
environment URL, so SaaS, Managed, and environment ActiveGate all work.

Two details worth knowing:

`ingest_event` exposes Dynatrace's event timeout as `eventTimeout`, not
`timeout`. The tool transport reserves `params.timeout` for the HTTP request
deadline, so the obvious name would have silently retargeted the wrong knob.

`get_metric` encodes its path segment with `encodeDynatracePathSegment`
rather than `encodeURIComponent`, which leaves the `:` separators in metric
keys and transformation operators intact, matching the docs' own examples.

* fix(dynatrace): close the gaps a validation pass turned up

Three real defects and one usability gap, all found by auditing the tools
against the Dynatrace API reference a second time.

`ingest_logs` double-encoded its payload. `logs` is a `json` param, and a
`json` param arrives as a *string* whenever it comes from a long-input field
or an LLM tool call — only a block-to-block reference hands over a parsed
value. `JSON.stringify` on that string produced `"[{...}]"`, so Dynatrace
received a quoted string where it expected an array. The block hid this in
the UI path by pre-parsing, but the parse lived in `tools.config.params` and
*threw* on malformed input, and it never covered the direct tool-call path at
all. Both tools now normalize through the shared `parseJsonParam`, so the
tool is correct regardless of who calls it, and the block just forwards the
raw value. `ingest_event.properties` had the identical bug.

Path identifiers were not trimmed. A problem or entity ID pasted with a
trailing newline became `%0A` in the URL and 404'd with nothing to suggest
whitespace was the cause.

Errors dropped the part that matters. Dynatrace's ErrorEnvelope carries
`constraintViolations[]`, which names the offending selector or parameter;
the generic `nested-error-object` extractor returns only `error.message`
("Constraints violated."), and which extractor won was left to fallback
order. Adds a `dynatrace-errors` extractor that folds the violations into the
message and pins it on all 22 tools. It sits after `nested-error-object` in
the chain, which already matches this shape, so no other service's error
handling changes.

Adds 21 tests covering URL construction for SaaS/Managed/ActiveGate, cursor
pagination dropping sibling filters, identifier trimming, metric-key colon
preservation, both JSON-param paths, the `eventTimeout` -> `timeout` mapping,
EntityStub flattening, the audit log's dotted `dt.settings.*` keys, and the
204/200 split on log ingestion.

* docs(dynatrace): add the page intro, and pin every response key in tests

Adds a MANUAL-CONTENT:intro block to the generated integration page covering
what the block reaches, how to get an environment URL and a scoped token for
SaaS vs Managed, how selectors work, and how cursor pagination behaves.
Verified it survives `generate-docs.ts` byte-identically.

Also closes the last silent-failure gap the validation pass left open. A
wrong top-level response key does not throw — it maps to an empty array and
reads as "no results", which is indistinguishable from a genuinely empty
environment. Dynatrace is unusually easy to get wrong here: the SLO list
returns `slo` (singular) and the metric query returns `result` (singular).
Adds a table-driven test asserting the documented key for all ten list
endpoints plus the scalar keys of the ingest and single-entity responses.
Confirmed it bites by flipping `data.slo` to `data.slos` and watching only
that row fail.

* chore(dynatrace): type the shared param map as unknown

Review follow-up. `Record<string, any>` in the block's params builder dropped
compile-time checking from every operation's shared params; `unknown` is
enough here since the values flow straight into the tool param maps. Matches
.claude/rules/sim-typescript.md, which sibling blocks (Datadog, Grafana)
still violate.

* fix(dynatrace): stop three silent failures found in a final read-through

All three turn a failed call into something that looks like a successful
empty one, which is the worst shape for an observability integration — you
cannot tell "nothing is wrong" from "the call did not work".

`readJsonBody` swallowed any unparseable body and returned `{}`. A gateway
HTML page, a captive-portal interstitial, or a truncated payload therefore
mapped every field to null and read as "no problems found". Only genuinely
empty bodies are tolerated now (201 from add-comment, 204 from log ingest);
anything else that will not parse raises with a truncated preview.

`ingest_logs` sent `[]` when the payload was missing or empty. Dynatrace
answers 204 to that, so the tool reported `accepted: true` for a call that
shipped no logs. It now fails loudly instead.

`encodeDynatracePathSegment` percent-encoded the whole metric key and then
regex-unescaped `%3A` back to `:`. Same output, but it undoes the encoder's
work and hides the intent. Colons are structural in a metric key, so it now
splits on them, encodes each part, and rejoins — which says that directly.

Each fix has a test, and each test was confirmed to fail in isolation with
only its own fix reverted.
2026-08-07 17:35:49 -07:00
Vikhyath Mondreti 5cf1f9be84 improvement(provenance): cleanup secrets boundary (#6374)
* fix(secrets): preserve raw outputs with durable provenance

* improvement(provenance): cleanup boundary

* fix copy resources

* fix fork copies to work with provenance

* address comments

* fix
2026-08-07 17:12:17 -07:00
Waleed 40c0a571fd fix(files): stop the file-viewer image reshift on open (#6390)
* fix(files): reserve embedded-image space on direct file-view loads

An embedded image in a markdown file reshifted on every open: it loaded ~2.3s in with no
reserved box and shoved everything below it down (CLS ~0.17).

The intrinsic dimensions ARE stored server-side, but the image node view never read them at
render. useWorkspaceImageDimensionsAdapter read the active files list via
queryClient.getQueryData — non-reactively — so on a cold file-view load it returned null at
first render and, because the adapter identity was stable, never re-checked when the list
later resolved.

Read the list via a reactive useWorkspaceFiles subscription instead: the adapter re-runs the
image node view's memoized dimension read when the list resolves, so it reserves the box from
the stored dimensions before the (slower) image download finishes. The query key is shared, so
it dedupes with surrounding views. Gate it behind `enabled` (driven by the absence of a
caller-supplied contentSource) so the public share page — which passes a share token as
workspaceId — doesn't fire a 404.

Verified in a CLS harness: dims present -> 0 shift; dims absent -> 0.20.

* fix(files): stop the duplicate 'mention' extension-name warning

The @-mention menu extension and the mention node were both named 'mention', so TipTap logged
"Duplicate extension names found: ['mention']" on every editor (twice under the collaborative
placeholder + live pair). Rename the menu extension to 'mentionMenu' (the node keeps 'mention',
its persisted doc-node type) and move its editor.storage.mention -> editor.storage.mentionMenu.
2026-08-07 15:48:08 -07:00
Justin BlumencranzandClaude Fable 5 9b80fddd00 fix(chunkers): preserve FAQ prose in docs chunks (#6383)
* fix(chunkers): preserve FAQ prose in docs chunks

cleanContent deleted every FAQ section from the embedding index: the
multiline tag strip swallows an entire <FAQ items={[...]}/> block (it
matches from <FAQ to the first ">", often inside an answer string), and
the brace strip eats any surviving { question, answer } items — 637
Q&As across the docs never reached search, with mangled JSX fragments
embedded in their place. Consume FAQ blocks whole before the tag strip
and emit their question/answer text as plain prose, escape-aware so
braces and quotes inside answers survive. Tag and brace stripping are
otherwise unchanged — a corpus survey showed FAQ props are the only
place real page prose lives inside JSX syntax on searchable pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chunkers): accept single-quoted FAQ items with trailing commas

session-policies.mdx and verified-domains.mdx write FAQ items with
single-quoted multiline values and trailing commas; the double-quote-only
item pattern matched nothing there, so the component consumer replaced
those whole FAQ blocks with a space. Capture either quote style
escape-aware (quotes of the other style inside a value are fine) and
allow the trailing comma; captured values keep their quotes and are
unquoted before unescaping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(chunkers): wrap the FAQ replace call for biome

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 13:56:52 -07:00
Waleed 73bac1adc2 improvement(admin): move user row actions into an overflow menu with confirm modals (#6384)
* improvement(admin): move user row actions into an overflow menu with confirm modals

* fix(admin): surface password reset status outside the actions menu

* fix(admin): surface ban and role change errors inside the confirm modal

* fix(admin): reset the ban mutation when opening the confirm modal

* improvement(admin): simplify the user row actions after review passes

* fix(admin): show password reset progress while the request is in flight

* fix(admin): confirm the role change the admin chose, not the live row's inverse

* improvement(admin): align the role confirm and reset feedback with house patterns
2026-08-07 13:17:04 -07:00
Siddharth Ganesan a84260fc56 improvement(mship): questions improvement (#6385)
* credentials continue

* fixes
2026-08-07 13:16:26 -07:00
Waleed de5fcf9731 fix(providers): stop reporting an absent Ollama as an error (#6387)
* fix(providers): stop reporting an absent Ollama as an error

Ollama is optional and its URL falls back to a loopback default, so a deployment
that runs none refuses the probe on every poll — 10,068 of these in 14 days, the
single largest error stream in the app, all of them the same expected condition.

Report it the way the vLLM and LiteLLM routes already report an unconfigured
base URL, and skip the probe entirely on the hosted platform, which has no local
runtime to reach. An explicit OLLAMA_URL is still honoured everywhere, so a
self-hosted deployment behaves exactly as before — including the localhost
default that requires no configuration.

* fix(providers): keep an unreadable Ollama response out of the not-reachable path

The single catch covered the connection, the JSON read, and the schema parse, so a
server that answered but answered wrongly was filed as 'no Ollama here'. Scope the
quiet path to the connection itself and report an unusable response as the fault
it is.
2026-08-07 12:52:51 -07:00
Waleed c9aed7af99 fix(jsm): accept the numeric pagination the JSM tools actually send (#6386)
* fix(jsm): accept the numeric pagination the JSM tools actually send

The JSM tools declare start/limit as type: 'number' and the block coerces
Max Results with Number.parseInt, but every /api/tools/jsm/* contract typed
them as z.string(). Any JSM read with pagination filled in 400'd before
reaching Atlassian, and get_queues 400'd unconditionally because the block
always sends includeCount as a boolean.

Normalize both shapes at the contract boundary, add the missing Start Index
block input, and route Max Results through the existing toOptionalInt helper
so a non-numeric entry no longer sends NaN.

* improvement(forking): widen the fork mapping target picker further

320px still clipped the longest secret keys the picker shows.

* fix(jsm): cap pagination at the documented int32 maximum

Addresses review: the schema claimed the int32 range but only floored at 0,
so values above 2147483647 were forwarded to Atlassian instead of being
rejected at Sim's boundary. Also restores the const tuple for the paginated
operation list and drops the widened ToolConfig from the test table.
2026-08-07 12:40:20 -07:00
Waleed d2964af7d2 fix(chat): re-measure the prompt editor when its width changes (#6380)
* fix(chat): re-measure the prompt editor when its width changes

The chat input's textarea grows to its full content height under a mirror
overlay, but it only re-measured on text change. A width change after typing
(window resize, sidebar toggle, resource panel opening) left the textarea at a
stale inline height while the overlay rewrapped taller. The spilled lines still
painted and scrolled but had no textarea beneath them, so clicks landed on the
scroller and never placed a caret.

Re-measure on width change only — the measure writes the textarea's height, so
reacting to height would feed itself.

* fix(chat): measure the observer's first delivery like any other

The width can change between the mount-time measure and observe(), so
treating the first notification as confirmation of the mount width dropped
that change and left the stale height in place.

* chore(chat): trim duplicated comments on the prompt editor autosize

The failure mode was documented in four places. Keeps one canonical
explanation next to the guard and leaves only the per-test whys the test
names do not already carry.
2026-08-07 11:29:20 -07:00
Waleed a7080d5fda improvement(forking): widen the fork mapping target picker and make it searchable (#6381)
* improvement(forking): widen the fork mapping target picker and make it searchable

* fix(forking): stop the truncated-candidates hint promising a search that cannot reach past the cap
2026-08-07 11:28:51 -07:00
Waleed eec3c352f3 fix(files): render the file-viewer placeholder through the live node views (#6379)
* fix(files): render the file-viewer placeholder through the live node views

The collaborative markdown viewer painted a static generateHTML placeholder while
the Yjs doc seeded, then swapped to the live editor. generateHTML runs only schema
renderHTML — never the React node views or the ProseMirror decoration plugins — so
every node whose live appearance comes from a node view or a decoration rendered
differently in the placeholder and visibly repainted on the swap: syntax highlighting
popped in, mention-chip icons shifted their labels, mermaid blocks jumped from source
to diagram, and media embeds appeared out of nowhere.

Render the placeholder through a read-only editor that shares the live editor's
extension set instead. It uses the same node views and decoration plugins, so the
placeholder is pixel-identical to the live editor and the swap neither repaints nor
reflows — highlighting, mention icons, images, mermaid (via its existing SVG cache),
and embeds (which already reserve their aspect-ratio box) all render up front. The
placeholder editor carries no Collaboration extension, Y.Doc, or awareness, so it
structurally cannot write to the shared document, preserving the seed-only-on-server
invariant; editable={false} disables every editing affordance.

* fix(files): address review on the placeholder editor

- Give ReadOnlyPlaceholder a named props interface (repo component convention).
- Render the placeholder synchronously (immediatelyRender: true) so it paints
  instantly like the static HTML it replaced instead of blanking for a frame
  while the editor mounts — safe because this surface is client-only, never SSR'd.
- Hoist the editor reading-column classes into a shared EDITOR_SURFACE_CLASS so
  the placeholder and live editor stay geometrically identical (drop the now
  redundant placeholderContent term from the live editor's hidden class).
2026-08-07 11:02:39 -07:00
Waleed 3f743d4b78 fix(uploads): treat a missing storage object as absent metadata, not a failure (#6378)
* fix(uploads): treat a missing storage object as absent metadata, not a failure

A workspace file is rewritten under a new key on every content update and the
superseded object is deleted, so any reader holding the previous key finds
nothing. getFileMetadata's provider lookups let that not-found propagate, so
authorization's catch-all logged it at ERROR and never reached the branch
already written for it. Return the function's established empty value instead,
and collapse the three divergent per-provider not-found predicates onto one.

* fix(uploads): read the not-found label from code as well as name

Azure raises a RestError whose name carries the class and whose code carries the
reason, so testing name first and falling back to code only when name was absent
missed BlobNotFound outright — narrower than the per-provider check it replaced.

* fix(uploads): keep a missing bucket or container out of the not-found path

NoSuchBucket and ContainerNotFound also answer 404, so the status-only match read
a total storage misconfiguration as an absent object — every file read would fail
closed with nothing left to alert on.

* fix(uploads): require an object-level label before treating a lookup as absent

GCS answers a missing object and a missing bucket identically, so a bare 404
cannot be attributed to the object by a dispatcher that does not know what was
requested. getFileMetadata now takes the labelled check and leaves an unlabelled
404 propagating as before; the provider clients keep the lenient form, which is
what each already used.

* refactor(uploads): let getFileMetadata delegate to the provider head helpers

getFileMetadata re-implemented the S3 and Blob HEAD calls inline, so it had to
inspect provider errors itself and needed a second, stricter predicate to do it
safely. headS3Object and headBlobObject already perform exactly those calls and
already report absence as null, so delegating removes the duplication, the error
inspection, and the extra predicate at once. GCS keeps raising, as before.

Covers the real provider path in the S3 client's own suite, where mocking the
seam had been hiding whether the two layers agree.

* fix(files): log a missing file at info rather than error when serving

Each serve handler rethrows into the outer one, so a superseded key produced two
ERROR lines for what is an ordinary 404 — two thirds of this module's error
volume. Route all five catch sites through one helper that reserves error for
failures that are actually the server's fault, matching how DocCompileUserError
is already handled a few lines above.

* test(uploads): cover the Blob not-found paths the shared predicate now governs

S3 and GCS already asserted absence and non-404 rethrow; Blob asserted neither,
so the container-level exclusion went unverified on the one provider whose error
puts the reason in code rather than name.
2026-08-07 11:00:01 -07:00
Waleed 77649d3982 fix(sidebar): keep the pin visible on the chat you're viewing (#6377)
The pin glyph carried a stale `!isCurrentRoute` guard copy-pasted from the
status dot back when the dot was also hidden on the current route. #4354 later
relaxed the dot's guard but left the pin's untouched, so opening a pinned chat
made its pin vanish.

Derive `showStatusDot` once and express the pin as its negation so the two
conditions can no longer drift apart. Also align the collapsed rail, which
never forwarded `isCurrentRoute` and so showed an unread dot on the chat you
were already reading, and hide the pin by the same opacity mechanism the dot
uses instead of a display toggle plus a mount guard.
2026-08-07 10:19:10 -07:00
Waleed 1305e9d723 chore(deps): bump mermaid to 11.16.1 and js-yaml to 4.3.1 to clear open Dependabot alerts (#6375) 2026-08-07 10:11:11 -07:00
Theodore Li e6a17b03e9 improvement(tables): show a tooltip on truncated column headers (#6371)
* improvement(tables): show a tooltip on truncated column headers

* chore(tables): use absolute import for HeaderLabel
2026-08-07 01:59:17 -04:00
Vikhyath MondretiandSim Pi Agent 69e3e177ac feat(library): Best AI Agent Builder in 2026: Sim Leads for Open-Source, Self-Hostable Teams (#6369)
Co-authored-by: Sim Pi Agent <pi@sim.ai>
2026-08-06 22:07:44 -07:00
Waleed 6599b4c428 fix(chat): keep the composer remove badge anchored and align chip tokens (#6365)
* fix(chat): keep the remove badge anchored to the file card

The card wrapper had no width cap, so it sized to the filename's max-content
width while the card itself capped at 220px. The remove badge is positioned
against that wrapper, so a long filename stranded it far to the right of the
card it belongs to.

Moves the cap onto the wrapper and lets the card fill it.

* fix(chat): make attachment tiles read on every surface they render on

The sent-message tile went icon-only, which made its fill the whole
affordance — and against the workflow chat panel's --surface-1 that fill is
~8/255 away in light mode. Adds the border the user message bubble already
pairs with --surface-5 for the same reason.

- Restore an accessible name to the sent tiles: an icon-only div with a title
  attribute announces as nothing.
- Step the composer icon badge on hover; the chip's hover fill closed to
  within 7/255 of it in light mode.
- Extension label moves to --text-icon/text-caption; --text-muted was 2.4:1
  on this fill in dark mode, well under AA.
- Tooltip.Content no longer re-declares the width and truncation it owns —
  it was truncating the very name it exists to reveal.

* improvement(chat): restore sent-attachment filenames and align chip tokens

- Revert the sent-message attachments to main's styling: the icon-only tile
  dropped the filename, leaving no way to tell what was sent.
- Radii onto the scale: --radius is 8px, so rounded-[10px] was off-system in
  both files. Outer surfaces use rounded-lg, the nested icon badge rounded-md.
- Pill filename uses the named text-xs rather than an arbitrary text-[11px].
- The remove badge is opaque instead of a translucent scrim, so it reads the
  same over a light card and over a photo rather than compositing with each.

* improvement(chat): tighten composer chip markup and tokens

- Remove the chip tooltips: the document card already shows its filename, so
  the tooltip mostly restated it.
- Collapse the single-use height constant and use size-[48px] on the media
  branch, which was h-[48px] + w-[48px] split across two class strings.
- Remove badge moves to --surface-2; --surface-1 sat 8/255 from the chip fill
  in light mode, reachable on a coarse pointer where the chip's hover-hover
  fill never applies. Its hover gating now matches the chip's.
- py-[7px] so the 32px icon badge fits the 48px box instead of overflowing it.
- Trim comments to TSDoc or one-line rationale per the repo rule.

* fix(chat): keep the remove badge reachable on coarse pointers

Gating the reveal on hover-hover alone would hide it from touch entirely,
since that variant is fine-pointer only. Instead it is visible by default and
only fine pointers get reveal-on-hover, so the badge never depends on an
emulated hover.

* fix(chat): reveal the remove control on keyboard focus

On a fine pointer the badge is transparent until hover, so tabbing to it left
a sighted keyboard user unable to see which attachment Enter would remove.
The focus-visible chain carries higher specificity than the hide rule, so it
wins regardless of source order.

* improvement(chat): drop the icon badge's own hover step

The chip's hover is the only hover affordance needed. The badge fill is now
constant, sitting one step below --surface-6 in light mode so the chip's
hover fill cannot close on it — which is what the per-badge step was
compensating for.

* fix(chat): stop gating the remove badge on a variant that cannot express it

hover-hover expands to '@media (hover:hover) and (pointer:fine) { &:hover }',
so it binds to the element carrying the class. On the badge that meant every
rule required hovering the badge itself, making the whole chain dead CSS —
the badge was simply always visible.

Rather than rebuild the gating, drop it: an always-visible control is
reachable on touch and stays visible while holding keyboard focus, which the
reveal-on-hover form could not manage without special cases for both.
2026-08-06 20:16:22 -07:00
Theodore Li 0601fcda5f fix(files): render HTML files shared via a public file link (#6363)
The public share page rooted on `.desktop-title-bar-page`, which sets only
`min-height: 100vh`. Without a definite height, `h-full` on every descendant of
`<main>` resolved to `auto` -> 0. `HtmlPreview` gates its sandboxed iframe on a
measured non-zero container, so it silently never mounted and the page rendered
a blank area under the header. Other read-only branches survived because their
content has intrinsic height.

Give the public page root a definite height, and make the read-only preview
chain flex-based so it fills its parent instead of depending on an ancestor's
definite height. `text-editor`'s preview pane becomes a flex column for the same
reason -- it is `HtmlPreview`'s other parent.
2026-08-06 22:35:46 -04:00
Waleed 1c0e82a4e2 perf(ci): parallelize repo audits, guard env-dependent tests, and fix the docs generator (#6358)
* perf(ci): parallelize the repo audits and guard env-dependent tests

The 21 independent audits ran as 21 sequential CI steps, each a single-threaded
read-only walk of the tree. scripts/run-audits.ts runs them concurrently:
28s serial -> 5.0s wall locally at 13-way. It buffers each audit's output and
replays only failures, so a green run stays quiet and a red one still names the
audit and shows why. Audits needing a git base ref (block registry, migration
safety) or that write files (drizzle generate) stay as their own steps.

Also fixes 5 tests that fail for every macOS dev and are invisible in CI. They
shell out to python3 using `match` statements and 3.12 f-string nesting, which
need >= 3.10; stock macOS ships 3.9.6, so `bun run test` produced raw Python
SyntaxErrors with no guard and nothing tying them to a missing tool. One also
needs ripgrep, which CI installs and a Mac usually does not.

@sim/testing/environment detects both and the tests skip with a reason via
vitest's ctx.skip(). Under CI it throws instead: these suites deliberately run
the real helper rather than a mock -- the cloud-review path/read-size bounds and
the placeholder compiler's generated Python are only observable that way -- so a
missing tool in CI means a security boundary silently stopped being covered,
which is worse than a red build.

Drops the Codecov upload. The workflow already documented it as a dead path:
nothing generates apps/sim/coverage, vitest runs without --coverage, and
fail_ci_if_error hides it, so it reported green having uploaded nothing.

* fix(ci): raise the python floor to 3.12 and stop the bridge audit serializing the batch

Two review findings, both real.

MIN_PYTHON was 3.10, chosen for the `match` statements the compiler suite
generates. But two of the three guarded tests also use PEP 701 f-strings --
reusing the outer quote, and embedding `#` -- which are 3.12. Verified on a real
3.11 interpreter: the match-guard test passes, the other two fail with
`f-string: unmatched '('` and `f-string expression part cannot include '#'`,
which is exactly the raw SyntaxError the guard exists to prevent. A 3.10 floor
let them through and failed anyway.

The audit parallelization did not speed CI up -- it slowed it down. Serially the
21 audits took ~31s; concurrently the batch took 39.2s wall, because
check:desktop-bridge went from 1s to 39.2s and became the entire wall clock while
the other 20 finished in 9s. It is the only audit that shells out through `bunx`,
which re-resolves the package against the shared install cache -- a network-backed
sticky-disk mount on CI. Cheap when it runs alone, serialized behind the others
when they run together. Spawning the resolved compiler entry point directly
removes that layer.

Verified the audit still fails on a breaking bridge change rather than passing
faster by doing less.

* fix(docs): unbreak the MDX build and read trigger config from the registry

The docs build has been failing on staging since the Smartlead merge:

  ./apps/docs/content/docs/en/integrations/smartlead.mdx
  Expected a closing tag for `<original>` before the end of `paragraph`

Tool descriptions are emitted as prose, and that path escaped only braces --
every table-cell path already escaped angle brackets. MDX reads `<` as the start
of a JSX tag, so a description like 'The copy is named "<original> - copy"' fails
the build outright. escapeMdxProse handles the MDX-hostile characters and leaves
pipes, parens and brackets alone, which are legal in prose and whose escaping
would mangle markdown links.

Trigger configuration now comes from the evaluated registry instead of regex over
source. Static parsing silently dropped every field whose builder assembled its
array imperatively or took a description as a parameter -- all ten Jira triggers
lost `webhookSecret` and `jqlFilter` that way, and Monday lost its config too, so
regenerating the docs was destructive. Reading real objects also deletes 232 lines
of parsing. Note `required` may be a condition object rather than `true`; only an
unconditional `true` renders as Required, matching the previous behavior.

Tool headings now show the tool's name ("A2A Send Message") rather than its id
(`a2a_send_message`), unformatted, across 241 generated pages. Names come from
tools/generated/tool-metadata.ts, which CI keeps in sync. These headings feed each
page's table of contents. a2a.mdx is hand-written, so its headings were updated
directly.

Also consolidates five hand-inlined copies of the escape chain into the
escapeMdxCell that already existed, and drops 44 comments that restated the line
below them. Generator: 4306 -> 4069 lines.

Every refactor step was verified against a golden manifest of all 289 generated
files -- proven deterministic across runs and proven to catch a one-character
change -- so the only output differences are the intended ones.

KNOWN GAP: extractTriggerOutputs still parses source and has the same blind spot;
it already drops one Jira output section on main. Regenerating is now safe for
trigger config but still lossy for trigger outputs.

* refactor(ci): derive the audit list and stop shelling out through bunx

Review pass over the audit runner and the tool guards.

The audit list was hand-maintained alongside package.json with nothing linking
them, and it had already drifted: check:cron-parity exists, passes, and ran in no
CI step at all. The list is now derived from the check:* scripts with an explicit
exclusion map, so a new audit is opted out deliberately rather than forgotten.
That picks up cron-parity — 22 audits now, not 21.

check-realtime-prune-graph.ts still shelled out through `bunx turbo`, the same
pattern that took the bridge audit from 1s to 39s once the audits ran
concurrently. Both now go through scripts/local-bin.ts, which resolves
node_modules/.bin — the same path check:native-typecheck asserts is the native
TypeScript 7 compiler, so the one guarded path is the one that runs.

Audits are spawned as their script rather than `bun run <name>`, which started a
bun process only to read package.json and start a second one.

Tool detection is memoized per process; it was re-spawning python3 on each of the
5 call sites, in every vitest worker. The CI throw is deliberately NOT memoized —
memoizing it would turn every call after the first into a silent skip, which is
the failure mode the guard exists to prevent. Verified it still throws for all
three guarded tests, not just the first.

Also: dropped the environment module from the @sim/testing barrel so
node:child_process stays out of unrelated consumers' module graphs, restored the
per-audit reporting the 21 separate steps used to give (collapsible groups, error
annotations, and a timing table they never had), and trimmed comments that
restated their code or duplicated the runner's own docs.

* fix(devin): give the 11 Devin tools real display names

Every Devin tool had its id as its `name` (`list_session_messages`), so the
generated docs rendered `### list_session_messages` where every other integration
renders a human name. It was the only integration doing this -- 11 of 4427 tools.

Names take the service prefix, matching the majority convention (3200 of 4416
names start with their service).

Also points the ship skill at check:audits instead of hand-listing the audits.
That copy had drifted five behind package.json: cron-parity, import-specifiers,
sql-date-binding, trigger-block-cycle and native-typecheck were all missing, so
shipping never ran them. It was the third copy of that list; there is now one.

* fix(docs): read trigger outputs from the registry too

Closes the gap left by the config fix: extractTriggerOutputs still parsed source,
so triggers whose outputs come from a builder call lost their tables. jira_webhook
had no output section at all.

The registry was not a drop-in, which is why the naive swap deleted 10,298 lines
earlier. The two sides encode nesting differently. A TriggerOutput marks a group
by OMITTING type and holding children as sibling keys:

  issue: { id: { type: 'number' }, title: { type: 'string' } }

while the renderer walks the JSON-Schema-ish shape the parser used to synthesize:

  issue: { type: 'object', properties: { id: …, title: … } }

formatOutputStructure only descends into .properties, so handing it the raw
registry value collapsed every nested group to one untyped row and dropped its
children. normalizeTriggerOutputs converts between the two, preserving leaves
that already declare properties/items and merging the 13 hybrid nodes that carry
both a type and inline children.

Measured across all 368 triggers before changing anything: 155 identical, 213
divergent, and the divergence was purely the nesting encoding — no node has a
non-string type, and a group never carries its own string description, so
leaf-vs-group classification is unambiguous. That is what makes a nested property
literally named 'description' (42 of them) survive.

Deletes the static path: extractTriggerOutputs, resolveTriggerBuilderFunction,
resolveTriggerOutputsConstant, readTriggerSiblingModules,
getWebhookProviderConstants, plus resolveConstStringValue and matchQuotedProperty
which the config fix had already stranded.

20 output sections recovered (linear 79->93, tiktok 6->11, jira 44->45) and 1698
rows. Verified independently: zero sections lost across all 289 generated files,
no file lost rows, output deterministic across regeneration.

The 96 deletions are all corrections, not losses. 70 are confluence fields the
parser flattened out of `comment: { ...buildContentEntityFields(), parent: {…} }`
and rendered as top-level trigger outputs; they reappear nested under their
parent in the same hunk. 8 are greenhouse key ordering, 6 are intercom
descriptions the parser had dropped, 1 is a vercel row moving position.

Generator: 4069 -> 3903 lines.

* chore(test): silence vite 8 deprecation warnings in the sim vitest config

@vitejs/plugin-react v4 targets pre-rolldown Vite: it sets `esbuild.jsx`
and `optimizeDeps.rollupOptions`, both deprecated under Vite 8's oxc
pipeline, and self-reports that plugin-react-oxc should be used instead.
v6 is that plugin merged back under the original name — it requires Vite
^8, drops Babel entirely, and emits none of those options.

Vite 8 also resolves tsconfig paths natively, so vite-tsconfig-paths is
replaced by `resolve.tsconfigPaths`.

Full apps/sim suite unchanged: 1483 passed / 2 skipped files,
20415 passed / 30 skipped tests.

* refactor(docs): drop 33 more comments that restated their code

Second pass over the generator, e.g. `// Copy icons from sim app to docs app`
above `copyIconsFile()`. Kept the multi-line runs (those carry reasoning), the
ones with concrete examples, and the one marking a deliberate empty catch.

Verified byte-identical output across all 289 generated files.
Generator: 3903 -> 3870 lines, 4306 at the start of this branch.

* refactor(ci): read package.json once in the audit runner

auditScripts() re-read the manifest the module body had already loaded.

* fix(pdl): name the tools directory after the tool ids

People Data Labs declared `pdl_*` tool ids under `tools/peopledatalabs/`. Every
other integration names the directory after its id prefix -- 259 of 260 before
this, and PDL was the only exception.

The docs generator locates a tool's definition by deriving the directory from the
id prefix, so it looked in `tools/pdl/`, found nothing, and returned null for all
11 tools. peopledatalabs.mdx rendered eleven bare `###` headings with no
description, no Input table and no Output table.

Renaming the directory rather than the ids: tool ids are persisted in saved
workflows, so renaming those would break existing users. The directory is
internal -- 15 files' imports.

Fixed at the source rather than teaching the generator a fallback. A special case
would have left the invariant broken and the next integration free to break it
again; now 260 of 260 hold, and the generator needs no exception.

peopledatalabs.mdx: 11 empty headings -> 456 lines. Repo-wide: zero pages with an
empty action body.
2026-08-06 19:08:32 -07:00
Waleed 8694f5581d fix(chat): render HEIC attachments and restyle composer file chips (#6361)
* fix(chat): render HEIC attachments and restyle composer file chips

The composer previewed every attachment through URL.createObjectURL of the
raw bytes. No browser decodes HEVC-coded HEIF, so a HEIC showed a broken
glyph, and the upload-completion handler never replaced that blob URL — so
it stayed broken even once a derivative was available.

- Skip the blob for HEIC/HEIF and pick up the serve URL (preview=1) once the
  upload lands, so the server derivative renders.
- Fall back to the type icon if the image still fails to decode.
- Documents render as labelled cards (icon, name, type) instead of a 9px
  extension caption; media keeps a thumbnail.
- Fix a blob-URL leak: the unmount cleanup closed over the first render's
  empty array and revoked nothing.

* fix(chat): make composer chips read against the composer shell

The composer is --white in light and --surface-4 in dark. The chip reused
chipFilledFillTokens (--surface-5 / dark:--surface-4), which assumes a page
background, so in dark mode the chip fill matched its own container exactly
and only the border showed. Same for the remove badge, which sits on the
shell and was 5/255 from it.

- Chip fills --surface-5 in both themes and hover steps away from the shell
  in each theme's 'raised' direction.
- Remove badge uses --surface-6, readable on white and on --surface-4.
- Cap the document card at min(220px,100%) so a long filename truncates on a
  narrow viewport instead of overflowing the composer.

* chore(chat): use the absolute alias for the chip test import
2026-08-06 19:00:41 -07:00