improvement(audits): enforce console, helper, render-path, persist, and deployment-flag rules (#8559)

* chore(lint): ban console in runtime code with Biome noConsole

Runtime code logs through createLogger from @sim/logger. Scripts, CLIs,
script-migrations, the logger itself, SDK examples, and tests keep console
as their interface. Autofix is disabled so lint --unsafe never silently
deletes a console call.

* refactor(utils): replace inline toError, isRecordLike, omit, and truncate idioms with @sim/utils helpers

* refactor(ui): lazy-init object refs and use size-* for equal height and width

useRef(new X()) built a throwaway X on every render; the refs now
lazy-init through ??= as sim-react-performance.md prescribes. Equal
h-N w-N pairs become size-N per sim-styling.md.

* improvement(audits): extend check:utils to the remaining written idioms

Adds toError, isRecordLike, filterUndefined, omit, truncate, and
escapeRegExp idioms from CLAUDE.md, plus render-path rules: ES2023
array methods in browser code, useRef(new X()), and h-N w-N pairs.

* improvement(audits): require an explicit partialize on every zustand persist

sim-stores.md requires persist to whitelist durable fields; check:zustand-v5
now fails on a persist with no partialize or one that spreads the whole
state. canvas-mode was the one store without it.

* improvement(audits): flag deployment-shape env-flags imports in client settings surfaces

check:client-boundary now fails when a 'use client' module under the
workspace, organization, or standalone settings surfaces imports isHosted,
isBillingEnabled, isChatEnabled, or an enterprise feature flag from
env-flags instead of reading the seeded deployment shape.

* docs(agents): name the check that enforces the common-utilities list

* docs(agents): scope the check:utils note to the forms it bans

* perf(audits): gate backreference patterns in check:utils behind literal prefilters

The h-N/w-N, toError, and truncate patterns backtrack from every word
boundary; a cheap literal test per file keeps the scan at ~1s of CPU.

* refactor(ui): lazy-init useRef containers the nested-generic pattern missed

Allocate Map/Set ref containers once instead of on every render, and drop the
redundant processedRemovalIds alias in the toast provider.

* improvement(audits): close detector gaps in check:utils and the deployment-shape rule

- check:utils: match useRef(new X()) with nested generics, honor utils-lint-allow
  above formatter-wrapped statements, drop h-screen/w-screen from the size-N rule,
  and skip server-only App Router files in the ES2023 rule
- deployment-shape rule: cover stores/, hooks/, blocks/ and surface hooks, read
  namespace imports, derive the flag list from deployment-shape.ts, parse long
  import clauses whole, and allowlist the panel store's module-init isChatEnabled
- zustand persist message names the hoisted-options escape
- biome: allow console in *.integration.ts, *.spec.ts, and desktop e2e

* improvement(audits): share the directive classifier and simplify check:utils

- move leadingDirective/directiveOn into scripts/source-kind.ts; check:utils uses it
  instead of its own 'use client' regex, and multi-line block-comment headers now parse
- replace the deployment-shape allowlist with a client-boundary-allow annotation on
  the panel store's isChatEnabled import
- exempt all of packages/utils/src by prefix (drops the stale retry.test.ts entry)
- build both truncate patterns from one shared fragment and prefilter
- add literal prefilters to isRecordLike, fromEntries, and useRef patterns and
  memoize prefilter results per file
- trim the deployment-shape rationale to a CLAUDE.md pointer

* refactor: drop isRecordLike pass-through wrappers and return audioLevels directly

useSpeechToText returns its stable, in-place-filled Float32Array instead of a
nullable ref; MicButton and the composer, search, and user-input props follow.

* improvement(audits): ban ES2023 array methods repo-wide, catch whole-state partialize, strip inline directive comments

* improvement(audits): follow aliased persist imports, require strict !== for filterUndefined, state the .with scope
This commit is contained in:
Waleed
2026-10-02 01:16:25 -07:00
committed by GitHub
parent 70412cc886
commit 588b8ce270
122 changed files with 786 additions and 498 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps
When rendering or sorting a list of rows against a lookup collection (members, folders, tags), keep the per-row work O(1):
- **Precompute a lookup `Map` once**, never `array.find(...)` per row. Build `const byId = useMemo(() => { const m = new Map<string, T>(); for (const x of items ?? []) m.set(x.id, x); return m }, [items])` and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n) — the worst offender. Depend memos on the derived `Map`, not the raw array.
- **Sort a copy with `[...array].sort(cmp)`**, never `toSorted` in client code — see `sim-react-performance.md` → "Never mutate a shared array in place".
- **Sort a copy with `[...array].sort(cmp)`**, never `toSorted` (`check:utils` bans it repo-wide) — see `sim-react-performance.md` → "Never mutate a shared array in place".
- **Partition in a single pass** — when splitting one collection into several (`fileIds`/`folderIds`), do one `for…of` pushing into each bucket and return `{ a, b }` from a single `useMemo`, not two memos that each `map→filter→map` the same source twice.
## react-doctor (`bunx react-doctor`) — apply the wins, skip the false positives
@@ -41,7 +41,7 @@ When rendering or sorting a list of rows against a lookup collection (members, f
react-doctor diagnostics are hypotheses, not verdicts — confirm against the code before acting, and preserve behavior. Known repo-specific false positives to NOT "fix":
- `no-barrel-import` — barrel imports are the repo convention (see sim-imports.md, "Barrel Exports"). Keep them.
- `js-tosorted-immutable` — won't-fix in `'use client'` code (see "List-render performance" above); apply it only in server-only modules.
- `js-tosorted-immutable` — won't-fix anywhere; `check:utils` bans the ES2023 array methods repo-wide.
- `rerender-state-only-in-handlers` / "state set but never rendered" — a false positive when the `useState` is consumed by a `useEffect`/`useLayoutEffect` dependency (the effect must re-run on change). Only convert to a ref when nothing reads the value reactively.
- `no-render-in-render` — a helper *called inline* (`{renderRow()}`) is reconciled by position and does **not** remount, so extracting it to a component is usually pure churn and can regress behavior (prop-drilling many closures, focus/scroll loss on the inner `<input>`). Apply it only when the helper is genuinely a *component defined during render*, or when the move is mechanical (a stateless, ref-free helper whose closures become a small, explicit prop set).
- `async-await-in-loop` on an upload/progress loop where sequential execution is intentional (per-item progress, server backpressure) — leave it.
+1 -1
View File
@@ -77,7 +77,7 @@ return items.sort(compare)
return [...items].sort(compare)
```
**Do NOT reach for `toSorted()` / `toReversed()` / `with()` / `toSpliced()` on client render paths.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is the correct default everywhere client code runs. Only consider the immutable methods in Node-only code (server routes, scripts) on Node ≥20, where the runtime is known.
**Do NOT use `toSorted()` / `toReversed()` / `with()` / `toSpliced()`.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is used everywhere: whether a module reaches the browser is not visible from its path. `check:utils` flags `toSorted`/`toReversed`/`toSpliced` repo-wide and `with` when called with a numeric index (an identifier index is indistinguishable from OpenTelemetry's `context.with`).
## Run independent awaits in parallel
+2 -2
View File
@@ -34,7 +34,7 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps
When rendering or sorting a list of rows against a lookup collection (members, folders, tags), keep the per-row work O(1):
- **Precompute a lookup `Map` once**, never `array.find(...)` per row. Build `const byId = useMemo(() => { const m = new Map<string, T>(); for (const x of items ?? []) m.set(x.id, x); return m }, [items])` and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n) — the worst offender. Depend memos on the derived `Map`, not the raw array.
- **Sort a copy with `[...array].sort(cmp)`**, never `toSorted` in client code — see `sim-react-performance.md` → "Never mutate a shared array in place".
- **Sort a copy with `[...array].sort(cmp)`**, never `toSorted` (`check:utils` bans it repo-wide) — see `sim-react-performance.md` → "Never mutate a shared array in place".
- **Partition in a single pass** — when splitting one collection into several (`fileIds`/`folderIds`), do one `for…of` pushing into each bucket and return `{ a, b }` from a single `useMemo`, not two memos that each `map→filter→map` the same source twice.
## react-doctor (`bunx react-doctor`) — apply the wins, skip the false positives
@@ -42,7 +42,7 @@ When rendering or sorting a list of rows against a lookup collection (members, f
react-doctor diagnostics are hypotheses, not verdicts — confirm against the code before acting, and preserve behavior. Known repo-specific false positives to NOT "fix":
- `no-barrel-import` — barrel imports are the repo convention (see sim-imports.md, "Barrel Exports"). Keep them.
- `js-tosorted-immutable` — won't-fix in `'use client'` code (see "List-render performance" above); apply it only in server-only modules.
- `js-tosorted-immutable` — won't-fix anywhere; `check:utils` bans the ES2023 array methods repo-wide.
- `rerender-state-only-in-handlers` / "state set but never rendered" — a false positive when the `useState` is consumed by a `useEffect`/`useLayoutEffect` dependency (the effect must re-run on change). Only convert to a ref when nothing reads the value reactively.
- `no-render-in-render` — a helper *called inline* (`{renderRow()}`) is reconciled by position and does **not** remount, so extracting it to a component is usually pure churn and can regress behavior (prop-drilling many closures, focus/scroll loss on the inner `<input>`). Apply it only when the helper is genuinely a *component defined during render*, or when the move is mechanical (a stateless, ref-free helper whose closures become a small, explicit prop set).
- `async-await-in-loop` on an upload/progress loop where sequential execution is intentional (per-item progress, server backpressure) — leave it.
+1 -1
View File
@@ -80,7 +80,7 @@ return items.sort(compare)
return [...items].sort(compare)
```
**Do NOT reach for `toSorted()` / `toReversed()` / `with()` / `toSpliced()` on client render paths.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is the correct default everywhere client code runs. Only consider the immutable methods in Node-only code (server routes, scripts) on Node ≥20, where the runtime is known.
**Do NOT use `toSorted()` / `toReversed()` / `with()` / `toSpliced()`.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is used everywhere: whether a module reaches the browser is not visible from its path. `check:utils` flags `toSorted`/`toReversed`/`toSpliced` repo-wide and `with` when called with a numeric index (an identifier index is indistinguishable from OpenTelemetry's `context.with`).
## Run independent awaits in parallel
+2 -2
View File
@@ -10,7 +10,7 @@ This file (also `AGENTS.md`) holds the repo-wide rules. Area detail lives in `.c
- **Logging**: `createLogger` from `@sim/logger`; `logger.info` / `logger.warn` / `logger.error`, never `console.log`. Inside `withRouteHandler` the logger already carries the request ID — no manual `withMetadata({ requestId })`.
- **Comments**: TSDoc for documentation. An inline `//` only for a terse, non-obvious why, or for a script-enforced `// <tag>: <reason>` annotation (`boundary-raw-fetch`, `double-cast-allowed`, `boundary-raw-json`, `untyped-response`, `rq-lint-allow`, `client-boundary-allow`, …). No `====` separators.
- **ID generation**: `generateId()` (UUID v4, the default) or `generateShortId(size?)` (URL-safe, 21 chars by default) from `@sim/utils/id` — never `crypto.randomUUID()`, `nanoid`, or `uuid`. Both use `crypto.getRandomValues()`, so they also work in non-secure (HTTP) browsers.
- **Common utilities**: use the shared helpers from `@sim/utils` instead of inline implementations:
- **Common utilities**: use the shared helpers from `@sim/utils` instead of inline implementations (`check:utils` bans most of the inline forms below):
- `sleep(ms)` from `@sim/utils/helpers` — never `new Promise(resolve => setTimeout(resolve, ms))`
- `toError(e)` from `@sim/utils/errors` — normalize caught values to `Error`; never `e instanceof Error ? e : new Error(String(e))`
- `getErrorMessage(e, fallback?)` from `@sim/utils/errors` — never `e instanceof Error ? e.message : 'fallback'`
@@ -89,7 +89,7 @@ The `'use client'` server boundary, the app/worker runtime env split, and featur
- **Imports**: absolute (`@/...`) only, never relative. A folder with 3+ exports gets an `index.ts` barrel; never re-export from a non-barrel file. `import type` for type-only imports. Order and lazy-loading through barrels: `.claude/rules/sim-imports.md`.
- **TypeScript**: no `any` and no non-null `!` (use precise types or `unknown` with guards; `check:explicit-any` ratchets both); no export nothing imports (`check:unused-exports`); a props interface for every component; `as const` for constant objects/arrays; explicit ref types (`useRef<HTMLDivElement>(null)`).
- **Unused bindings** fail lint (biome `noUnusedVariables`, `noUnusedFunctionParameters`): delete the dead variable, import, or parameter and update callers; write `catch {}` when the error is unused. Prefix `_` only for a parameter that must hold its position because a later one is used. `const { a, ...rest } = obj` to omit keys is allowed. The rules carry no autofix, so `bun run lint` will not rename anything for you.
- **Components**: `'use client'` only for hooks or browser APIs. Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()` never `toSorted()` on client paths): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI.
- **Components**: `'use client'` only for hooks or browser APIs. Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()`, never `toSorted()`): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI.
- **State ownership**: React Query owns server data — never `useState` + `fetch`; shareable client view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks: `.claude/rules/sim-hooks.md`. Stores (`devtools`, `persist` only with an explicit `partialize` whitelist, workflow value invariants): `.claude/rules/sim-stores.md`. URL state: `.claude/rules/sim-url-state.md`.
- **Utils**: inline a helper with one consumer; create `utils.ts` when 2+ files share it — in `lib/` (app-wide) or `feature/utils/` (feature-scoped). Check `lib/` before writing a new one.
- **Lists and menus** mirror the order the user already reads elsewhere (toolbar, settings nav), encoded in one exported order constant (resource menus share `RESOURCE_MENU_ORDER`, a product order that does not mirror the sidebar); a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`.
+2 -3
View File
@@ -19,7 +19,7 @@ import {
} from '@sim/desktop-bridge/local-filesystem-limits'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { escapeRegExp } from '@sim/utils/string'
import { escapeRegExp, truncate } from '@sim/utils/string'
import { app, dialog, shell } from 'electron'
import micromatch from 'micromatch'
import safeRegex from 'safe-regex2'
@@ -1236,8 +1236,7 @@ export class LocalFilesystemService {
matches.push({
uri: resultUri,
line: request.lineNumbers === false ? 0 : contextIndex + 1,
text:
line.length > MAX_GREP_LINE_LENGTH ? `${line.slice(0, MAX_GREP_LINE_LENGTH)}…` : line,
text: truncate(line, MAX_GREP_LINE_LENGTH, '…'),
})
if (matches.length >= maxResults) {
truncated = true
+4 -1
View File
@@ -1,8 +1,11 @@
import { createLogger } from '@sim/logger'
import { sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, docsEmbeddings } from '@/lib/db'
import { generateSearchEmbedding } from '@/lib/embeddings'
const logger = createLogger('DocsSearchAPI')
export const runtime = 'nodejs'
export const revalidate = 0
@@ -196,7 +199,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(searchResults)
} catch (error) {
console.error('Semantic search error:', error)
logger.error('Semantic search error:', error)
return NextResponse.json([])
}
+4 -1
View File
@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { getLLMText } from '@/lib/llms'
import { source } from '@/lib/source'
const logger = createLogger('DocsLlmsFullText')
export const revalidate = false
export async function GET() {
@@ -18,7 +21,7 @@ export async function GET() {
},
})
} catch (error) {
console.error('Error generating LLM full text:', error)
logger.error('Error generating LLM full text:', error)
return new Response('Error generating full documentation text', { status: 500 })
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
import { createLogger } from '@sim/logger'
import { source } from '@/lib/source'
import { DOCS_BASE_URL } from '@/lib/urls'
const logger = createLogger('DocsLlmsManifest')
export const revalidate = false
export async function GET() {
@@ -70,7 +73,7 @@ See: https://llmstxt.org for specification`
},
})
} catch (error) {
console.error('Error generating LLM manifest:', error)
logger.error('Error generating LLM manifest:', error)
return new Response('Error generating documentation manifest', { status: 500 })
}
}
@@ -113,7 +113,7 @@ export function VideoPlaceholder({
pendingSeek.current = null
}
}}
className='h-full w-full border-0'
className='size-full border-0'
>
<track
kind='captions'
@@ -63,7 +63,7 @@ export function BlockPreview({ type }: BlockPreviewProps) {
panOnDrag={false}
panOnScroll={false}
preventScrolling={false}
className='h-full w-full [--text-muted:var(--text-secondary)]! [--xy-background-color:var(--bg)]'
className='size-full [--text-muted:var(--text-secondary)]! [--xy-background-color:var(--bg)]'
/>
</ReactFlowProvider>
</LazyMotion>
@@ -39,7 +39,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({
}
return (
<div className={cn('h-full w-full', data.isDimmed && DIMMED_PREVIEW_CLASS)}>
<div className={cn('size-full', data.isDimmed && DIMMED_PREVIEW_CLASS)}>
<SubflowNodeView
id={id}
data={subflowData}
@@ -224,7 +224,7 @@ function PreviewFlow({
autoPanOnNodeDrag={false}
proOptions={PRO_OPTIONS}
minZoom={0.1}
className='h-full w-full [--text-muted:var(--text-secondary)]! [--xy-background-color:var(--bg)]'
className='size-full [--text-muted:var(--text-secondary)]! [--xy-background-color:var(--bg)]'
>
<FitViewAfterInit options={interactive ? LIGHTBOX_FIT_VIEW_OPTIONS : FIT_VIEW_OPTIONS} />
</ReactFlow>
@@ -329,7 +329,7 @@ export function WorkflowPreview({
className='fixed inset-0 m-auto h-[86dvh] max-h-none w-[92vw] max-w-none overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)] p-0 text-[var(--text-body)] backdrop:bg-black/70 backdrop:backdrop-blur-sm'
>
{expanded && (
<div className='relative flex h-full w-full flex-col sm:flex-row'>
<div className='relative flex size-full flex-col sm:flex-row'>
<div className='relative min-h-0 min-w-0 flex-1'>
<div className='pointer-events-none absolute top-0 right-0 left-0 z-10 flex items-center justify-between px-4 py-3'>
<span className='text-[var(--text-muted)] text-small'>{workflow.name}</span>
+1
View File
@@ -20,6 +20,7 @@
"dependencies": {
"@sim/db": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
"@sim/utils": "workspace:*",
"@sim/workflow-renderer": "workspace:*",
"@xyflow/react": "12.11.3",
@@ -177,10 +177,10 @@ export const ChatInput: React.FC<{
<img
src={file.dataUrl}
alt={file.name}
className='h-full w-full object-cover'
className='size-full object-cover'
/>
) : (
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--text-muted)]'>
<div className='flex size-full flex-col items-center justify-center gap-0.5 text-[var(--text-muted)]'>
<Paperclip className='size-[18px]' />
<span className='max-w-[48px] truncate px-[2px] text-[9px]'>
{file.name.split('.').pop()}
@@ -179,7 +179,7 @@ export function HeroChatLoop({
return (
<div
className='relative flex h-full w-full flex-col bg-[var(--bg)]'
className='relative flex size-full flex-col bg-[var(--bg)]'
data-chat-view={welcome ? 'welcome' : 'conversation'}
data-chat-phase={phase}
>
@@ -266,7 +266,7 @@ export function HeroPlatformLoop() {
>
<div
ref={previewContainerRef}
className='relative flex h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)] [--preview-resource-width:50%]'
className='relative flex size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)] [--preview-resource-width:50%]'
>
<div className={cn('relative h-full min-w-0 flex-1', stageOpen && 'max-lg:hidden')}>
<HeroChatLoop
@@ -338,7 +338,7 @@ export function HeroPlatformLoop() {
>
<div
className={cn(
'h-full w-full transition-[transform,opacity] duration-[480ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
'size-full transition-[transform,opacity] duration-[480ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
stageOpen ? 'translate-x-0 opacity-100' : 'translate-x-6 opacity-0'
)}
>
@@ -350,7 +350,7 @@ export function StageBlockCard({
if (onSelect) handleKeyboardActivation(event, () => onSelect(block.id))
}}
className={cn(
'relative h-full w-full rounded-2xl',
'relative size-full rounded-2xl',
onSelect && 'cursor-pointer focus-visible:outline-none'
)}
>
@@ -31,12 +31,13 @@ interface WorkflowMenuPreviewProps {
/** A cropped production canvas with a clear center and progressively softened inner edges. */
export function WorkflowMenuPreview({ layout = 'menu', onReady }: WorkflowMenuPreviewProps) {
const readyBlocksRef = useRef(new Set<string>())
const readyBlocksRef = useRef<Set<string> | null>(null)
const readyBlocks = (readyBlocksRef.current ??= new Set())
const handleBlockReady = (blockId: string) => {
if (readyBlocksRef.current.has(blockId)) return
readyBlocksRef.current.add(blockId)
if (readyBlocksRef.current.size === BLOCKS.length) onReady?.()
if (readyBlocks.has(blockId)) return
readyBlocks.add(blockId)
if (readyBlocks.size === BLOCKS.length) onReady?.()
}
return (
@@ -20,7 +20,7 @@ interface PlatformHeroVisualProps {
*/
export function PlatformHeroVisual({ children }: PlatformHeroVisualProps) {
return (
<div className='relative h-full w-full'>
<div className='relative size-full'>
<Image
fill
priority
@@ -87,7 +87,7 @@ export function BuildAgentsLoop() {
<HeroLoopShell chats={PREVIEW_SIDEBAR_CHATS} workflows={PREVIEW_SIDEBAR_WORKFLOWS}>
<div
className={cn(
'relative flex h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)] transition-opacity duration-300 ease-out',
'relative flex size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)] transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -112,7 +112,7 @@ export function BuildAgentsLoop() {
stageOpen ? 'w-1/2 border-l' : 'w-0 border-l-0'
)}
>
<div className='h-full w-full'>
<div className='size-full'>
<HeroResourcePanel
activeId='workflow'
builtCount={builtCount}
@@ -107,11 +107,11 @@ export function GovernanceLoop() {
className='pointer-events-none absolute inset-0'
contentClassName='bg-[var(--surface-1)] p-2'
>
<div className='h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div
key={cycleId}
className={cn(
'flex h-full w-full flex-col transition-opacity duration-300 ease-out',
'flex size-full flex-col transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -98,10 +98,10 @@ export function EditorLoop({ content }: EditorLoopProps) {
workflows={content.sidebarWorkflows}
activeItem={content.activeItem}
>
<div className='h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div
className={cn(
'h-full w-full transition-opacity duration-300 ease-out',
'size-full transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -107,7 +107,7 @@ export function SolutionsCard({ card, headingId, tabletSpan = false }: Solutions
wide && 'sm:max-lg:mt-0 sm:max-lg:w-auto sm:max-lg:min-w-0'
)}
>
<div className='h-full w-full'>{card.visual}</div>
<div className='size-full'>{card.visual}</div>
</div>
</div>
</article>
@@ -8,7 +8,7 @@ import { SOLUTIONS_VISUAL } from '@/app/(landing)/components/solutions-page/cons
* hero-visual family: `--surface-2` fill, `--border-1` hairline, `rounded-lg`,
* `overflow-hidden`) and, crucially, its dimensions: it reserves a full-width
* 16:9 aspect ratio for the solutions hero visual. Because the size is reserved
* before paint and the node fills `h-full w-full` inside, a dropped-in node can
* before paint and the node fills `size-full` inside, a dropped-in node can
* neither shift surrounding layout (CLS = 0) nor change the frame's own padding.
*
* The frame is decorative chrome around product visuals, so it is `aria-hidden`;
@@ -29,7 +29,7 @@ export function SolutionsVisualFrame({ children }: SolutionsVisualFrameProps) {
SOLUTIONS_VISUAL.heroAspect
)}
>
<div className='h-full w-full'>{children}</div>
<div className='size-full'>{children}</div>
</div>
)
}
@@ -74,7 +74,7 @@ export const SOLUTIONS_TEXT_MEASURE = {
* Reserved fixed dimensions for the component-owned visual frames. A dropped-in
* `ReactNode` renders into a frame of exactly these dimensions, so it can never
* shift surrounding layout (CLS = 0) nor change its own frame padding. The node
* fills `h-full w-full` inside; it owns nothing about the frame.
* fills `size-full` inside; it owns nothing about the frame.
*/
export const SOLUTIONS_VISUAL = {
/** Full-width hero visual aspect ratio - reserves height before paint. */
@@ -105,7 +105,7 @@ export const SOLUTIONS_VISUAL = {
featureTileContainer: '[container-type:inline-size]',
featureTileScale: '[--tile-scale:min(1,tan(atan2(100cqw,352px)))]',
featureTileCanvas:
'h-[calc(100%/var(--tile-scale,1))] w-[calc(100%/var(--tile-scale,1))] origin-top-left [scale:var(--tile-scale,1)]',
'size-[calc(100%/var(--tile-scale,1))] origin-top-left [scale:var(--tile-scale,1)]',
/**
* Feature-tile minimum height - the `440px` design-space height, multiplied
* by the tile's current scale so height tracks the zoomed content. The
@@ -231,7 +231,7 @@ export function ContactForm() {
>
<div
aria-hidden='true'
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
className='pointer-events-none absolute left-[-9999px] size-px overflow-hidden opacity-0'
>
<label htmlFor='contact-website'>Website</label>
<input
@@ -97,7 +97,7 @@ export function DemoBooking({ className }: DemoBookingProps) {
style={{ '--demo-card-h': formHeight ? `${formHeight}px` : undefined } as CSSProperties}
>
<div
className='flex h-full w-full transition-transform duration-200 ease-out motion-reduce:transition-none'
className='flex size-full transition-transform duration-200 ease-out motion-reduce:transition-none'
style={{ transform: showScheduler ? 'translateX(-100%)' : undefined }}
>
<div
@@ -110,11 +110,7 @@ export function DemoBooking({ className }: DemoBookingProps) {
<DemoForm onComplete={setLead} />
</div>
</div>
<div
ref={schedulerPanelRef}
className='h-full w-full min-w-0 shrink-0'
inert={!showScheduler}
>
<div ref={schedulerPanelRef} className='size-full min-w-0 shrink-0' inert={!showScheduler}>
{lead ? <DemoScheduler lead={lead} /> : null}
</div>
</div>
@@ -139,7 +139,7 @@ export function EnterpriseHomeStage({
return (
<div
className={cn(
'relative h-full w-full bg-[var(--bg)] transition-opacity duration-300 ease-out',
'relative size-full bg-[var(--bg)] transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -100,7 +100,7 @@ export function EnterprisePlatformLoop({
chats={content.sidebarChats}
workflows={content.sidebarWorkflows}
>
<div className='flex h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='flex size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='relative h-full min-w-0 flex-1'>
<EnterpriseHomeStage
phase={phase}
@@ -120,7 +120,7 @@ export function EnterprisePlatformLoop({
>
<div
className={cn(
'h-full w-full transition-opacity duration-300 ease-out',
'size-full transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -32,7 +32,7 @@ export function FeatureGraphicShell({ children, variant = 'tile' }: FeatureGraph
return (
<div
className={cn(
'relative h-full w-full overflow-hidden',
'relative size-full overflow-hidden',
!portrait &&
'mx-auto min-h-[260px] max-w-[420px] sm:max-lg:[@container(min-width:500px)]:max-w-none'
)}
@@ -244,10 +244,10 @@ export function FilesHeroLoop() {
return (
<HeroLoopShell chats={SIDEBAR_CHATS} workflows={SIDEBAR_WORKFLOWS} activeItem='Files'>
<div className='h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div
className={cn(
'flex h-full w-full flex-col transition-opacity duration-300 ease-out',
'flex size-full flex-col transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -747,7 +747,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
<div className='px-6 pt-10 pb-4'>
<div className='mb-2 flex items-center gap-2.5'>
<span className='relative flex size-2' aria-hidden='true'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75' />
<span className='absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75' />
<span className='relative inline-flex size-2 rounded-full bg-emerald-500' />
</span>
<h2
@@ -85,7 +85,7 @@ export function ConnectorSyncGraphic() {
data-feature-graphic='connectors'
className='absolute inset-0 flex justify-center p-5 [container-type:inline-size]'
>
<div className='flex h-full w-full flex-col'>
<div className='flex size-full flex-col'>
<div className='mb-5 flex items-center justify-between'>
<span className='text-[var(--text-primary)] text-base'>Connectors</span>
<ChipTag variant='mono'>Auto-sync</ChipTag>
@@ -163,10 +163,10 @@ export function KnowledgeHeroLoop() {
return (
<HeroLoopShell chats={SIDEBAR_CHATS} workflows={SIDEBAR_WORKFLOWS} activeItem='Knowledge bases'>
<div className='h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div
className={cn(
'flex h-full w-full flex-col transition-opacity duration-300 ease-out',
'flex size-full flex-col transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -275,11 +275,11 @@ export function LogsHeroLoop() {
return (
<HeroLoopShell chats={SIDEBAR_CHATS} workflows={SIDEBAR_WORKFLOWS} activeItem='Logs'>
<div className='h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div
key={cycleId}
className={cn(
'flex h-full w-full flex-col transition-opacity duration-300 ease-out',
'flex size-full flex-col transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -314,7 +314,7 @@ function TablesGridPane({ rowCount, filledCount }: TablesGridPaneProps) {
const Icon = column.type === 'boolean' ? TypeBoolean : TypeText
return (
<th key={column.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<div className='flex size-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-[var(--text-primary)] text-small'>
{column.label}
@@ -391,10 +391,10 @@ export function TablesHeroLoop() {
return (
<HeroLoopShell chats={SIDEBAR_CHATS} workflows={SIDEBAR_WORKFLOWS} activeItem='Tables'>
<div className='h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div className='size-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
<div
className={cn(
'h-full w-full transition-opacity duration-300 ease-out',
'size-full transition-opacity duration-300 ease-out',
fading ? 'opacity-0' : 'opacity-100'
)}
>
@@ -71,7 +71,7 @@ export function WorkflowCanvasGraphic() {
width={LAYOUT.width}
height={LAYOUT.height}
maxScale={Number.POSITIVE_INFINITY}
className='h-full w-full'
className='size-full'
contentClassName='relative'
>
<svg
@@ -33,6 +33,7 @@ function isRadixIdMismatch(args: unknown[]): boolean {
*/
export function HydrationErrorHandler() {
useEffect(() => {
// biome-ignore lint/suspicious/noConsole: wraps console.error itself to filter extension hydration noise
const originalError = console.error
console.error = (...args) => {
if (typeof args[0] === 'string' && args[0].includes('Hydration')) {
+6 -7
View File
@@ -3,6 +3,7 @@ import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
@@ -61,7 +62,7 @@ function isDynamicClientRegistrationUnsupported(error: unknown): boolean {
export function surfaceOauthError(error: unknown): string {
// Spec-compliant OAuth servers throw typed subclasses with clean RFC 6749 fields.
if (error instanceof OAuthError && !(error instanceof ServerError)) {
return truncate(`${error.errorCode}: ${error.message}`)
return truncateSurfacedError(`${error.errorCode}: ${error.message}`)
}
// ServerError wraps non-spec response bodies as "HTTP N: Invalid OAuth error
@@ -76,18 +77,16 @@ export function surfaceOauthError(error: unknown): string {
(typeof body.message === 'string' && body.message) ||
(typeof body.error === 'string' && body.error) ||
null
if (vendorMessage) return truncate(`Authorization server: ${vendorMessage}`)
if (vendorMessage) return truncateSurfacedError(`Authorization server: ${vendorMessage}`)
} catch {}
}
return truncate(error.message.split('\n')[0] || 'Failed to start OAuth flow')
return truncateSurfacedError(error.message.split('\n')[0] || 'Failed to start OAuth flow')
}
return 'Failed to start OAuth flow'
}
function truncate(message: string): string {
return message.length > MAX_SURFACED_ERROR_LENGTH
? `${message.slice(0, MAX_SURFACED_ERROR_LENGTH)}…`
: message
function truncateSurfacedError(message: string): string {
return truncate(message, MAX_SURFACED_ERROR_LENGTH, '…')
}
export const dynamic = 'force-dynamic'
@@ -121,7 +121,7 @@ export function SearchInputBar({
/>
}
/>
<div aria-hidden className='pointer-events-none absolute h-0 w-0 overflow-hidden'>
<div aria-hidden className='pointer-events-none absolute size-0 overflow-hidden'>
<span ref={textMeasureRef} className={cn(PROMPT_TEXT_CLASSES, 'whitespace-pre')}>
{value}
</span>
@@ -175,7 +175,7 @@ beforeEach(() => {
mocks.speech.mockReturnValue({
isSupported: true,
isListening: false,
audioLevelsRef: { current: new Float32Array(5) },
audioLevels: new Float32Array(5),
toggleListening: mocks.toggleListening,
resetTranscript: mocks.resetTranscript,
})
@@ -249,7 +249,7 @@ export function Composer({
)
const voiceControl = voice.isSupported && (
<MicButton
audioLevelsRef={voice.audioLevelsRef}
audioLevels={voice.audioLevels}
isListening={voice.isListening}
onToggle={voice.toggleListening}
/>
@@ -88,7 +88,7 @@ function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) {
voiceControl={
voice.isSupported && (
<MicButton
audioLevelsRef={voice.audioLevelsRef}
audioLevels={voice.audioLevels}
isListening={voice.isListening}
onToggle={voice.toggleListening}
/>
@@ -25,7 +25,7 @@ export function FileViewer() {
<DesktopTitleBarLane />
<iframe
src={serveUrl}
className='h-full w-full border-0'
className='size-full border-0'
title={file.name}
onError={() => {
logger.error(`Failed to load file: ${file.name}`)
@@ -72,7 +72,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
className='pointer-events-none fixed h-px w-px'
className='pointer-events-none fixed size-px'
style={{ left: `${position.x}px`, top: `${position.y}px` }}
tabIndex={-1}
aria-hidden
@@ -24,7 +24,7 @@ export const ImagePreview = memo(function ImagePreview({ file }: { file: Workspa
return (
<div className='relative flex min-h-0 flex-1 flex-col'>
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
<ZoomablePreview className='flex flex-1' contentClassName='size-full'>
<img
src={serveUrl}
alt={file.name}
@@ -456,7 +456,7 @@ const HtmlPreview = memo(function HtmlPreview({
sandbox='allow-scripts'
referrerPolicy='no-referrer'
title='HTML Preview'
className='h-full w-full border-0 bg-[var(--surface-2)]'
className='size-full border-0 bg-[var(--surface-2)]'
/>
)}
</div>
@@ -473,7 +473,7 @@ function SvgPreview({ content }: { content: string }) {
}, [content])
return (
<ZoomablePreview className='min-h-0 flex-1' contentClassName='h-full w-full'>
<ZoomablePreview className='min-h-0 flex-1' contentClassName='size-full'>
{blobUrl && (
<img
src={blobUrl}
@@ -14,6 +14,7 @@ import {
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { useQueryClient } from '@tanstack/react-query'
import {
ACTIVITY_ICON_SLOT_CLASS,
@@ -60,7 +61,7 @@ function argsPreview(params: Record<string, unknown> | undefined): string | unde
if (!params || Object.keys(params).length === 0) return undefined
try {
const json = JSON.stringify(params, null, 2)
return json.length > 600 ? `${json.slice(0, 600)}…` : json
return truncate(json, 600, '…')
} catch {
return undefined
}
@@ -41,7 +41,7 @@ afterEach(() => {
describe('claimPermissionResponse', () => {
it('allows one response per request id across effect recreation', () => {
const handledRequestIds = { current: new Set<string>() }
const handledRequestIds = new Set<string>()
expect(claimPermissionResponse(handledRequestIds, 'request-1')).toBe(true)
expect(claimPermissionResponse(handledRequestIds, 'request-1')).toBe(false)
@@ -63,7 +63,7 @@ describe('browser permission prompt', () => {
}
it('blocks replaced and unmounted requests once without overriding an explicit answer', () => {
const handledRequestIds = { current: new Set<string>() }
const handledRequestIds = new Set<string>()
const responses = vi.fn()
const onDecision = (
requestId: string,
@@ -193,15 +193,15 @@ const MAX_HANDLED_PERMISSION_REQUESTS = 256
/** Claims the one renderer response allowed for a native browser permission request. */
export function claimPermissionResponse(
handledRequestIds: { current: Set<string> },
handledRequestIds: Set<string>,
requestId: string
): boolean {
if (handledRequestIds.current.has(requestId)) return false
handledRequestIds.current.add(requestId)
while (handledRequestIds.current.size > MAX_HANDLED_PERMISSION_REQUESTS) {
const oldest = handledRequestIds.current.values().next().value
if (handledRequestIds.has(requestId)) return false
handledRequestIds.add(requestId)
while (handledRequestIds.size > MAX_HANDLED_PERMISSION_REQUESTS) {
const oldest = handledRequestIds.values().next().value
if (typeof oldest !== 'string') break
handledRequestIds.current.delete(oldest)
handledRequestIds.delete(oldest)
}
return true
}
@@ -449,7 +449,8 @@ export function BrowserSession({
const toolbarMenuButtonRef = useRef<HTMLButtonElement>(null)
const omniboxFocusRafRef = useRef<number | null>(null)
const omniboxPointerSelectionRef = useRef<OmniboxPointerSelection | null>(null)
const handledPermissionRequestIdsRef = useRef<Set<string>>(new Set())
const handledPermissionRequestIdsRef = useRef<Set<string> | null>(null)
const handledPermissionRequestIds = (handledPermissionRequestIdsRef.current ??= new Set())
const [answeredPermissionRequestId, setAnsweredPermissionRequestId] = useState<string | null>(
null
)
@@ -505,7 +506,7 @@ export function BrowserSession({
action: ReturnType<typeof browserPermissionResponseAction>,
allowed: boolean
) => {
if (!claimPermissionResponse(handledPermissionRequestIdsRef, requestId)) {
if (!claimPermissionResponse(handledPermissionRequestIds, requestId)) {
return
}
setAnsweredPermissionRequestId(requestId)
@@ -1,6 +1,6 @@
'use client'
import { memo, type RefObject, useEffect, useRef } from 'react'
import { memo, useEffect, useRef } from 'react'
import { Button, cn, Tooltip, usePrefersReducedMotion } from '@sim/emcn'
import { Mic } from '@sim/emcn/icons'
@@ -12,16 +12,16 @@ const WAVEFORM_EASING = 0.24
interface MicButtonProps {
isListening: boolean
audioLevelsRef: RefObject<Float32Array>
audioLevels: Float32Array
onToggle: () => void
}
interface VoiceWaveformProps {
audioLevelsRef: RefObject<Float32Array>
audioLevels: Float32Array
isListening: boolean
}
function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) {
function VoiceWaveform({ audioLevels, isListening }: VoiceWaveformProps) {
const prefersReducedMotion = usePrefersReducedMotion()
const barRefs = useRef<Array<SVGLineElement | null>>([])
@@ -32,10 +32,8 @@ function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) {
let animationFrameId = 0
const draw = () => {
const levels = audioLevelsRef.current
for (let index = 0; index < WAVEFORM_BAR_COUNT; index++) {
const level = levels?.[index] ?? 0
const level = audioLevels[index] ?? 0
const targetHeight =
WAVEFORM_MIN_HEIGHT +
Math.sqrt(Math.max(0, level)) * (WAVEFORM_MAX_HEIGHT - WAVEFORM_MIN_HEIGHT)
@@ -53,7 +51,7 @@ function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) {
animationFrameId = window.requestAnimationFrame(draw)
return () => window.cancelAnimationFrame(animationFrameId)
}, [audioLevelsRef, isListening, prefersReducedMotion])
}, [audioLevels, isListening, prefersReducedMotion])
return (
<svg aria-hidden viewBox='0 0 18 18' className='size-[18px] overflow-hidden'>
@@ -81,7 +79,7 @@ function VoiceWaveform({ audioLevelsRef, isListening }: VoiceWaveformProps) {
export const MicButton = memo(function MicButton({
isListening,
audioLevelsRef,
audioLevels,
onToggle,
}: MicButtonProps) {
return (
@@ -105,7 +103,7 @@ export const MicButton = memo(function MicButton({
isListening ? 'scale-100 opacity-100 blur-none' : 'scale-[0.25] opacity-0 blur-[4px]'
)}
>
<VoiceWaveform audioLevelsRef={audioLevelsRef} isListening={isListening} />
<VoiceWaveform audioLevels={audioLevels} isListening={isListening} />
</span>
<Mic
strokeWidth='1.25'
@@ -24,7 +24,7 @@ export const SendButton = React.memo(function SendButton({
title='Stop generation'
aria-label='Stop generation'
>
<StopFilled className='block h-[14px] w-[14px] fill-white dark:fill-black' />
<StopFilled className='block size-[14px] fill-white dark:fill-black' />
</ComposerActionButton>
)
}
@@ -35,7 +35,7 @@ export const SendButton = React.memo(function SendButton({
disabled={!canSubmit}
active={canSubmit}
>
<ArrowUp className='block h-[16px] w-[16px] text-white dark:text-black' />
<ArrowUp className='block size-[16px] text-white dark:text-black' />
</ComposerActionButton>
)
})
@@ -324,7 +324,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
}
const {
audioLevelsRef,
audioLevels,
isListening,
isSupported: isSttSupported,
toggleListening,
@@ -641,7 +641,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
voiceControl={
isSttSupported && (
<MicButton
audioLevelsRef={audioLevelsRef}
audioLevels={audioLevels}
isListening={isListening}
onToggle={toggleListening}
/>
@@ -59,17 +59,21 @@ export function useFilePreviewController({
}: FilePreviewControllerDeps) {
const queryClient = useQueryClient()
const previewActivationOwnerRef = useRef<Map<string, string | null>>(new Map())
const completedPreviewResourceHandoffRef = useRef<
Map<string, { sessionId: string; suppressActivation: boolean }>
>(new Map())
const previewActivationOwnerRef = useRef<Map<string, string | null> | null>(null)
const previewActivationOwners = (previewActivationOwnerRef.current ??= new Map())
const completedPreviewResourceHandoffRef = useRef<Map<
string,
{ sessionId: string; suppressActivation: boolean }
> | null>(null)
const completedPreviewResourceHandoffs = (completedPreviewResourceHandoffRef.current ??=
new Map())
const rememberPreviewActivationOwner = useCallback(
(session: FilePreviewSession) => {
if (!session.fileId || previewActivationOwnerRef.current.has(session.id)) {
if (!session.fileId || previewActivationOwners.has(session.id)) {
return
}
previewActivationOwnerRef.current.set(session.id, activeResourceIdRef.current)
previewActivationOwners.set(session.id, activeResourceIdRef.current)
},
[activeResourceIdRef]
)
@@ -81,7 +85,7 @@ export function useFilePreviewController({
}
if (onResourceEventRef.current) return true
const currentActiveResourceId = activeResourceIdRef.current
const activationOwnerId = previewActivationOwnerRef.current.get(session.id)
const activationOwnerId = previewActivationOwners.get(session.id)
return (
currentActiveResourceId === null ||
currentActiveResourceId ===
@@ -219,8 +223,8 @@ export function useFilePreviewController({
const resetEphemeralPreviewState = useCallback(
(options?: { removeStreamingResource?: boolean }) => {
previewActivationOwnerRef.current.clear()
completedPreviewResourceHandoffRef.current.clear()
previewActivationOwners.clear()
completedPreviewResourceHandoffs.clear()
latestPreviewTargetToolCallIdRef.current = null
syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE)
resetPreviewSessions()
@@ -380,7 +384,7 @@ export function useFilePreviewController({
if (shouldActivateOnComplete) {
requestResourceAttention(fileId)
}
completedPreviewResourceHandoffRef.current.set(fileId, {
completedPreviewResourceHandoffs.set(fileId, {
sessionId: nextSession.id,
suppressActivation: !shouldActivateOnComplete,
})
@@ -224,7 +224,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
const completedPreviewHandoff =
resource.type === 'file'
? completedPreviewResourceHandoffRef.current.get(resource.id)
? completedPreviewResourceHandoffRef.current?.get(resource.id)
: undefined
const matchingPreviewSessions =
resource.type === 'file'
@@ -249,8 +249,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
(!latestActivePreviewForResource ||
latestActivePreviewForResource.id === completedPreviewHandoff.sessionId)
if (completedPreviewHandoff && !isCompletedPreviewHandoffCurrent) {
completedPreviewResourceHandoffRef.current.delete(resource.id)
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
completedPreviewResourceHandoffRef.current?.delete(resource.id)
previewActivationOwnerRef.current?.delete(completedPreviewHandoff.sessionId)
}
const shouldSuppressFileResourceActivation =
(isCompletedPreviewHandoffCurrent && completedPreviewHandoff?.suppressActivation === true) ||
@@ -284,8 +284,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
)
}
if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) {
completedPreviewResourceHandoffRef.current.delete(resource.id)
previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId)
completedPreviewResourceHandoffRef.current?.delete(resource.id)
previewActivationOwnerRef.current?.delete(completedPreviewHandoff.sessionId)
}
if (pinnedViewId) {
// Carry the newest pin on an existing tab so a remount adopts it. Not gated
@@ -167,7 +167,7 @@ export interface StreamLoopDeps {
chatIdRef: MutableRefObject<string | undefined>
selectedChatIdRef: MutableRefObject<string | undefined>
streamIdRef: MutableRefObject<string | undefined>
revealedSimKeysRef: MutableRefObject<RevealedSimKeysByMessage>
revealedSimKeys: RevealedSimKeysByMessage
pendingUserMsgRef: MutableRefObject<PersistedMessage | null>
activeTurnRef: MutableRefObject<ActiveTurn | null>
resourcesRef: MutableRefObject<MothershipResource[]>
@@ -182,10 +182,11 @@ export interface StreamLoopDeps {
previewSessionsRef: MutableRefObject<Record<string, FilePreviewSession>>
latestPreviewTargetToolCallIdRef: MutableRefObject<string | null>
activePreviewSessionIdRef: MutableRefObject<string | null>
completedPreviewResourceHandoffRef: MutableRefObject<
Map<string, { sessionId: string; suppressActivation: boolean }>
>
previewActivationOwnerRef: MutableRefObject<Map<string, string | null>>
completedPreviewResourceHandoffRef: MutableRefObject<Map<
string,
{ sessionId: string; suppressActivation: boolean }
> | null>
previewActivationOwnerRef: MutableRefObject<Map<string, string | null> | null>
}
export interface StreamLoopOps {
@@ -264,7 +265,7 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
deps.streamingBlocksRef.current = modelBlocks
deps.streamingContentRef.current = modelContent
captureRevealedSimKeys(
deps.revealedSimKeysRef.current,
deps.revealedSimKeys,
[deps.assistantId, state.streamRequestId],
modelContent,
modelBlocks
@@ -2,7 +2,6 @@ import type { MutableRefObject } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { vi } from 'vitest'
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
import type { RevealedSimKeysByMessage } from '@/lib/mothership/chat/sim-key-redaction'
import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract'
import type {
ActiveTurn,
@@ -72,7 +71,7 @@ export function makeStreamLoopDeps(overrides: Partial<StreamLoopDeps> = {}): Str
chatIdRef: ref<string | undefined>(undefined),
selectedChatIdRef: ref<string | undefined>(undefined),
streamIdRef: ref<string | undefined>(undefined),
revealedSimKeysRef: ref<RevealedSimKeysByMessage>(new Map()),
revealedSimKeys: new Map(),
pendingUserMsgRef: ref<PersistedMessage | null>(null),
activeTurnRef: ref<ActiveTurn | null>(null),
resourcesRef: ref<MothershipResource[]>([]),
@@ -693,7 +693,8 @@ export function useChat(
const [activeResourceId, setActiveResourceId] =
options?.activeResourceState ?? internalActiveResourceState
const onResourceEventRef = useRef(options?.onResourceEvent)
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage>(new Map())
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage | null>(null)
const revealedSimKeys = (revealedSimKeysRef.current ??= new Map())
onResourceEventRef.current = options?.onResourceEvent
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
@@ -720,8 +721,8 @@ export function useChat(
const clearQueueDispatchState = useCallback(() => {
queueDispatchEpochRef.current++
queueDispatchActionsRef.current = []
queuedMessageDispatchIdsRef.current.clear()
userRemovedDuringDispatchRef.current.clear()
queuedMessageDispatchIds.clear()
userRemovedDuringDispatch.clear()
queueDispatchTaskRef.current = null
setDispatchingHeadId(null)
}, [])
@@ -736,6 +737,10 @@ export function useChat(
*/
const undisplayableResourcesRef = useRef<MothershipResource[]>([])
const resourcePersistenceQueueRef = useRef<ResourcePersistenceQueue | null>(null)
const pendingResourceReordersRef = useRef<Map<string, MothershipResource[]> | null>(null)
const pendingResourceReorders = (pendingResourceReordersRef.current ??= new Map())
const pendingResourceReorderFlushesRef = useRef<Map<string, Promise<void>> | null>(null)
const pendingResourceReorderFlushes = (pendingResourceReorderFlushesRef.current ??= new Map())
const refreshResourceHistory = useCallback(
async (chatId: string) => {
/** Cancel pre-write reads without rolling back newer optimistic changes. */
@@ -745,7 +750,7 @@ export function useChat(
const queue = resourcePersistenceQueueRef.current
if (!current || !queue) return current
const resources = queue.applyPendingUpdates(chatId, current.resources)
const pendingOrder = pendingResourceReordersRef.current.get(chatId)
const pendingOrder = pendingResourceReorders.get(chatId)
return {
...current,
resources: pendingOrder
@@ -777,8 +782,6 @@ export function useChat(
})
}
const resourcePersistenceQueue = resourcePersistenceQueueRef.current
const pendingResourceReordersRef = useRef(new Map<string, MothershipResource[]>())
const pendingResourceReorderFlushesRef = useRef(new Map<string, Promise<void>>())
// Sentinel used while no `chatId` is resolved; `adoptResolvedChatId`
// migrates this bucket onto the real chatId on first send. Rotated on
@@ -865,11 +868,13 @@ export function useChat(
)
const editingQueuedId = useMothershipQueueStore((state) => state.editing[chatKey] ?? null)
const [dispatchingHeadId, setDispatchingHeadId] = useState<string | null>(null)
const queuedMessageDispatchIdsRef = useRef<Set<string>>(new Set())
const queuedMessageDispatchIdsRef = useRef<Set<string> | null>(null)
const queuedMessageDispatchIds = (queuedMessageDispatchIdsRef.current ??= new Set())
// Ids the user explicitly removed while a dispatch was in flight — used to
// suppress the dispatch's failure-restore path, which would otherwise undo
// the user's removal silently.
const userRemovedDuringDispatchRef = useRef<Set<string>>(new Set())
const userRemovedDuringDispatchRef = useRef<Set<string> | null>(null)
const userRemovedDuringDispatch = (userRemovedDuringDispatchRef.current ??= new Set())
const queueDispatchActionsRef = useRef<QueueDispatchAction[]>([])
const queueDispatchTaskRef = useRef<Promise<void> | null>(null)
const queueDispatchEpochRef = useRef(0)
@@ -918,7 +923,9 @@ export function useChat(
const reconnectExhaustedRecheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const detachedChatResolutionControllersRef = useRef<Set<AbortController>>(new Set())
const detachedChatResolutionControllersRef = useRef<Set<AbortController> | null>(null)
const detachedChatResolutionControllers = (detachedChatResolutionControllersRef.current ??=
new Set())
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
const chatIdRef = useRef<string | undefined>(initialChatId)
const tableViewContextsRef = useRef({
@@ -971,9 +978,13 @@ export function useChat(
const resourceActivityTrackerRef = useRef<ResourceActivityTracker | null>(null)
const streamingContentRef = useRef('')
const streamingBlocksRef = useRef<ContentBlock[]>([])
const handledClientWorkflowToolIdsRef = useRef<Set<string>>(new Set())
const handledClientLocalFilesystemToolIdsRef = useRef<Set<string>>(new Set())
const recoveringClientWorkflowToolIdsRef = useRef<Set<string>>(new Set())
const handledClientWorkflowToolIdsRef = useRef<Set<string> | null>(null)
const handledClientWorkflowToolIds = (handledClientWorkflowToolIdsRef.current ??= new Set())
const handledClientLocalFilesystemToolIdsRef = useRef<Set<string> | null>(null)
const handledClientLocalFilesystemToolIds = (handledClientLocalFilesystemToolIdsRef.current ??=
new Set())
const recoveringClientWorkflowToolIdsRef = useRef<Set<string> | null>(null)
const recoveringClientWorkflowToolIds = (recoveringClientWorkflowToolIdsRef.current ??= new Set())
const isHomePage = pathname.endsWith('/home')
const setTransportIdle = useCallback(() => {
@@ -1099,12 +1110,12 @@ export function useChat(
const flushPendingResourceReorder = useCallback(
(chatId: string): Promise<void> => {
const activeFlush = pendingResourceReorderFlushesRef.current.get(chatId)
const activeFlush = pendingResourceReorderFlushes.get(chatId)
if (activeFlush) return activeFlush
const flush = async () => {
while (true) {
const pendingOrder = pendingResourceReordersRef.current.get(chatId)
const pendingOrder = pendingResourceReorders.get(chatId)
if (!pendingOrder) return
if (resourcePersistenceQueue.hasPendingIdentityChanges(chatId)) return
@@ -1115,7 +1126,7 @@ export function useChat(
}
if (pendingOrder.length === 0) {
pendingResourceReordersRef.current.delete(chatId)
pendingResourceReorders.delete(chatId)
return
}
try {
@@ -1123,8 +1134,8 @@ export function useChat(
body: { chatId, resources: pendingOrder },
})
await refreshResourceHistory(chatId)
if (pendingResourceReordersRef.current.get(chatId) === pendingOrder) {
pendingResourceReordersRef.current.delete(chatId)
if (pendingResourceReorders.get(chatId) === pendingOrder) {
pendingResourceReorders.delete(chatId)
}
} catch (error) {
// 400 is the server rejecting the body's identity set — a tab was
@@ -1133,8 +1144,8 @@ export function useChat(
// re-establish the order. Everything else (offline, 401, 5xx) is
// transient and keeps the body for the next retry.
const unsatisfiable = isApiClientError(error) && error.status === 400
if (unsatisfiable && pendingResourceReordersRef.current.get(chatId) === pendingOrder) {
pendingResourceReordersRef.current.delete(chatId)
if (unsatisfiable && pendingResourceReorders.get(chatId) === pendingOrder) {
pendingResourceReorders.delete(chatId)
}
logger.warn(
unsatisfiable
@@ -1147,11 +1158,11 @@ export function useChat(
}
}
const tracked = flush().finally(() => {
if (pendingResourceReorderFlushesRef.current.get(chatId) === tracked) {
pendingResourceReorderFlushesRef.current.delete(chatId)
if (pendingResourceReorderFlushes.get(chatId) === tracked) {
pendingResourceReorderFlushes.delete(chatId)
}
})
pendingResourceReorderFlushesRef.current.set(chatId, tracked)
pendingResourceReorderFlushes.set(chatId, tracked)
return tracked
},
[refreshResourceHistory, resourcePersistenceQueue]
@@ -1296,7 +1307,7 @@ export function useChat(
source.splice(ownerIndex + 1, 0, liveAssistant)
}
}
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeys))
}, [chatHistory, pendingMessages, pendingTurn, liveContent, liveBlocks])
const addResource = useCallback(
(resourceUpdate: MothershipResourceUpdate): boolean => {
@@ -1488,7 +1499,7 @@ export function useChat(
...newOrder.filter((resource) => !isEphemeralResource(resource)),
...undisplayableResourcesRef.current,
]
pendingResourceReordersRef.current.set(persistChatId, persistableResources)
pendingResourceReorders.set(persistChatId, persistableResources)
void flushPendingResourceReorder(persistChatId)
},
[flushPendingResourceReorder]
@@ -1529,13 +1540,13 @@ export function useChat(
if (!isWorkflowToolName(toolName)) {
return
}
if (handledClientWorkflowToolIdsRef.current.has(toolCallId)) {
if (handledClientWorkflowToolIds.has(toolCallId)) {
return
}
if (recoveringClientWorkflowToolIdsRef.current.has(toolCallId)) {
if (recoveringClientWorkflowToolIds.has(toolCallId)) {
return
}
handledClientWorkflowToolIdsRef.current.add(toolCallId)
handledClientWorkflowToolIds.add(toolCallId)
ensureWorkflowToolResource(toolArgs)
executeRunToolOnClient(toolCallId, toolName, toolArgs)
@@ -1551,10 +1562,10 @@ export function useChat(
) {
return
}
if (handledClientLocalFilesystemToolIdsRef.current.has(toolCallId)) {
if (handledClientLocalFilesystemToolIds.has(toolCallId)) {
return
}
handledClientLocalFilesystemToolIdsRef.current.add(toolCallId)
handledClientLocalFilesystemToolIds.add(toolCallId)
const options = {
workspaceId,
chatId: chatIdRef.current ?? selectedChatIdRef.current,
@@ -1674,12 +1685,12 @@ export function useChat(
if (!toolCall || !isWorkflowToolName(toolCall.name)) continue
if (toolCall.status !== 'executing') continue
if (
handledClientWorkflowToolIdsRef.current.has(toolCall.id) ||
recoveringClientWorkflowToolIdsRef.current.has(toolCall.id)
handledClientWorkflowToolIds.has(toolCall.id) ||
recoveringClientWorkflowToolIds.has(toolCall.id)
) {
continue
}
recoveringClientWorkflowToolIdsRef.current.add(toolCall.id)
recoveringClientWorkflowToolIds.add(toolCall.id)
pending.push(toolCall)
}
}
@@ -1692,15 +1703,15 @@ export function useChat(
if (targetWorkflowId) {
const rebound = await bindRunToolToExecution(toolCall.id, targetWorkflowId)
if (rebound) {
handledClientWorkflowToolIdsRef.current.add(toolCall.id)
handledClientWorkflowToolIds.add(toolCall.id)
continue
}
}
recoveringClientWorkflowToolIdsRef.current.delete(toolCall.id)
recoveringClientWorkflowToolIds.delete(toolCall.id)
startClientWorkflowTool(toolCall.id, toolCall.name, toolArgs)
} finally {
recoveringClientWorkflowToolIdsRef.current.delete(toolCall.id)
recoveringClientWorkflowToolIds.delete(toolCall.id)
}
}
},
@@ -1733,7 +1744,7 @@ export function useChat(
// background so its native resources are re-keyed onto the server
// chat even though this reader is intentionally being cancelled.
const detachedResolutionController = new AbortController()
detachedChatResolutionControllersRef.current.add(detachedResolutionController)
detachedChatResolutionControllers.add(detachedResolutionController)
void (async () => {
const resolution = await waitForDetachedChatResolution(
() =>
@@ -1778,7 +1789,7 @@ export function useChat(
})
})
.finally(() => {
detachedChatResolutionControllersRef.current.delete(detachedResolutionController)
detachedChatResolutionControllers.delete(detachedResolutionController)
})
}
// Detach the current UI from the old stream without cancelling it on the server.
@@ -1948,7 +1959,7 @@ export function useChat(
// A stored panel this client cannot open is kept out of the tab strip
// rather than restored onto an error, but stays in the stored set so the
// desktop app still gets it back.
const pendingOrder = pendingResourceReordersRef.current.get(chatHistory.id)
const pendingOrder = pendingResourceReorders.get(chatHistory.id)
const projectedResources = pendingOrder
? (reorderStoredChatResources(updatedResources, pendingOrder) ?? updatedResources)
: updatedResources
@@ -2209,7 +2220,7 @@ export function useChat(
chatIdRef,
selectedChatIdRef,
streamIdRef,
revealedSimKeysRef,
revealedSimKeys,
pendingUserMsgRef,
activeTurnRef,
resourcesRef,
@@ -4588,17 +4599,17 @@ export function useChat(
queuedSendHandoff?: QueuedSendHandoffSeed
}
) => {
if (queuedMessageDispatchIdsRef.current.has(msg.id)) {
if (queuedMessageDispatchIds.has(msg.id)) {
return
}
queuedMessageDispatchIdsRef.current.add(msg.id)
queuedMessageDispatchIds.add(msg.id)
const dispatchChatKey = chatKeyRef.current
const queueAtStart =
useMothershipQueueStore.getState().queues[dispatchChatKey] ?? EMPTY_MESSAGE_QUEUE
let originalIndex = queueAtStart.findIndex((queued) => queued.id === msg.id)
if (originalIndex === -1) {
queuedMessageDispatchIdsRef.current.delete(msg.id)
queuedMessageDispatchIds.delete(msg.id)
return
}
@@ -4642,7 +4653,7 @@ export function useChat(
}
// If the user explicitly removed this message during dispatch, honor
// that and don't re-insert on failure.
if (userRemovedDuringDispatchRef.current.delete(msg.id)) {
if (userRemovedDuringDispatch.delete(msg.id)) {
clearQueuedSendHandoffState(msg.id)
return
}
@@ -4721,8 +4732,8 @@ export function useChat(
restoreQueuedMessage(activeQueuedSendHandoff)
} finally {
setDispatchingHeadId((current) => (current === msg.id ? null : current))
queuedMessageDispatchIdsRef.current.delete(msg.id)
userRemovedDuringDispatchRef.current.delete(msg.id)
queuedMessageDispatchIds.delete(msg.id)
userRemovedDuringDispatch.delete(msg.id)
}
},
[startSendMessage, handOffWithdrawnSend]
@@ -4778,8 +4789,8 @@ export function useChat(
const removeFromQueue = useCallback((id: string) => {
// If the message is mid-dispatch, mark it so the dispatch's failure-restore
// path won't silently undo the user's removal.
if (queuedMessageDispatchIdsRef.current.has(id)) {
userRemovedDuringDispatchRef.current.add(id)
if (queuedMessageDispatchIds.has(id)) {
userRemovedDuringDispatch.add(id)
}
clearQueuedSendHandoffState(id)
clearQueuedSendHandoffClaim(id)
@@ -4793,7 +4804,7 @@ export function useChat(
const queue = queueState.queues[chatKey]
const msg = id === undefined ? queue?.[0] : queue?.find((queued) => queued.id === id)
if (!msg || queueState.editing[chatKey] === msg.id) return
if (queuedMessageDispatchIdsRef.current.has(msg.id)) return
if (queuedMessageDispatchIds.has(msg.id)) return
const admissionPending = hasPendingChatAdmission()
// Explicit queue sends should supersede any older auto-drain work scheduled by finalize().
@@ -4858,7 +4869,7 @@ export function useChat(
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
// Reject edits on a message already mid-dispatch; the slot is about to be
// dropped. UI also disables this via `dispatchingHeadId`.
if (queuedMessageDispatchIdsRef.current.has(id)) return undefined
if (queuedMessageDispatchIds.has(id)) return undefined
const activeChatKey = chatKeyRef.current
const queue = useMothershipQueueStore.getState().queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE
const msg = queue.find((m) => m.id === id)
@@ -4887,7 +4898,7 @@ export function useChat(
)
if (chatHistory.activeStreamId) acceptedMessageIds.add(chatHistory.activeStreamId)
for (const queued of messageQueue) {
if (queuedMessageDispatchIdsRef.current.has(queued.id)) continue
if (queuedMessageDispatchIds.has(queued.id)) continue
const requestId = queued.queuedSendHandoff?.userMessageId ?? queued.resumeUserMessageId
if (!requestId || !acceptedMessageIds.has(requestId)) continue
clearQueuedSendHandoffState(queued.id)
@@ -4928,10 +4939,10 @@ export function useChat(
cancelActiveStreamReader()
abortControllerRef.current?.abort('unmount:client_cleanup')
abortControllerRef.current = null
for (const controller of detachedChatResolutionControllersRef.current) {
for (const controller of detachedChatResolutionControllers) {
controller.abort('unmount:detached_chat_resolution')
}
detachedChatResolutionControllersRef.current.clear()
detachedChatResolutionControllers.clear()
clearActiveTurn()
sendingRef.current = false
// Release the editing slot — the composer it binds to is unmounting.
@@ -67,13 +67,14 @@ export function IntegrationSkillsSection({
const createSkill = useCreateSkill()
const skillsReady = !isPending && !isPlaceholderData
const [pendingNames, setPendingNames] = useState<ReadonlySet<string>>(new Set())
const inFlightRef = useRef<Set<string>>(new Set())
const inFlightRef = useRef<Set<string> | null>(null)
const inFlight = (inFlightRef.current ??= new Set())
const existingNames = useMemo(() => new Set(existingSkills.map((s) => s.name)), [existingSkills])
const handleAdd = async (skill: SuggestedSkill, position: number) => {
if (inFlightRef.current.has(skill.name)) return
inFlightRef.current.add(skill.name)
if (inFlight.has(skill.name)) return
inFlight.add(skill.name)
setPendingNames((prev) => new Set(prev).add(skill.name))
try {
await createSkill.mutateAsync({ workspaceId, skill })
@@ -94,7 +95,7 @@ export function IntegrationSkillsSection({
toast.error(`Failed to add "${skill.name}" — please try again`)
}
} finally {
inFlightRef.current.delete(skill.name)
inFlight.delete(skill.name)
setPendingNames((prev) => {
const next = new Set(prev)
next.delete(skill.name)
@@ -123,7 +123,8 @@ function focusedElementOwnsShortcut(parsed: ParsedShortcut, isMac: boolean): boo
}
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const registryRef = useRef<Map<string, RegistryCommand> | null>(null)
const registry = (registryRef.current ??= new Map())
const isMac = useMemo(() => isMacPlatform(), [])
const router = useRouter()
@@ -132,7 +133,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
for (const cmd of commands) {
const id = cmd.id ?? generateId()
const parsed = cmd.shortcut ? parseShortcut(cmd.shortcut) : null
registryRef.current.set(id, {
registry.set(id, {
...cmd,
id,
parsed,
@@ -143,7 +144,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
return () => {
for (const id of createdIds) {
registryRef.current.delete(id)
registry.delete(id)
}
}
}, [])
@@ -152,7 +153,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return
for (const [, cmd] of registryRef.current) {
for (const [, cmd] of registry) {
if (!cmd.parsed) continue
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue
@@ -175,7 +176,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
}, [isMac, router])
const invoke = useCallback((id: string): boolean => {
const cmd = registryRef.current.get(id)
const cmd = registry.get(id)
if (!cmd) return false
try {
cmd.handler(new KeyboardEvent('keydown'))
@@ -335,7 +335,7 @@ export function General() {
width={36}
height={36}
unoptimized
className={`h-full w-full object-cover transition-opacity duration-300 ${
className={`size-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
@@ -385,7 +385,7 @@ export function General() {
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='absolute top-0 left-0 h-full w-full border-0 bg-transparent p-0 text-base outline-hidden focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0'
className='absolute top-0 left-0 size-full border-0 bg-transparent p-0 text-base outline-hidden focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={updateProfile.isPending}
autoComplete='off'
@@ -18,7 +18,8 @@ interface SelectOptionsEditorProps {
* option and focus jumps into it so typing flows straight through.
*/
export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorProps) {
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const inputElementsRef = useRef<Map<string, HTMLInputElement> | null>(null)
const inputRefs = (inputElementsRef.current ??= new Map())
const trailingRef = useRef<HTMLInputElement>(null)
const [pendingFocusId, setPendingFocusId] = useState<string | null>(null)
@@ -26,7 +27,7 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
// registered by the time this effect runs.
useEffect(() => {
if (!pendingFocusId) return
const el = inputRefs.current.get(pendingFocusId)
const el = inputRefs.get(pendingFocusId)
if (el) {
el.focus()
const end = el.value.length
@@ -40,7 +41,7 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
}
const remove = (id: string) => {
inputRefs.current.delete(id)
inputRefs.delete(id)
onChange(options.filter((o) => o.id !== id))
}
@@ -57,8 +58,8 @@ export function SelectOptionsEditor({ options, onChange }: SelectOptionsEditorPr
<div key={option.id} className='flex items-center gap-1.5'>
<ChipInput
ref={(el) => {
if (el) inputRefs.current.set(option.id, el)
else inputRefs.current.delete(option.id)
if (el) inputRefs.set(option.id, el)
else inputRefs.delete(option.id)
}}
value={option.name}
onChange={(e) => update(option.id, { name: e.target.value })}
@@ -59,7 +59,8 @@ export function TableFilter({
onClose,
}: TableFilterProps) {
const lastAppliedFilterRef = useRef<string | undefined>(undefined)
const deferredAppliedRulesRef = useRef<Map<string, FilterRule>>(new Map())
const deferredAppliedRulesRef = useRef<Map<string, FilterRule> | null>(null)
const deferredAppliedRules = (deferredAppliedRulesRef.current ??= new Map())
const [rules, setRules] = useState<FilterRule[]>(() => {
const fromFilter = predicateToFilterRules(filter)
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
@@ -86,7 +87,7 @@ export function TableFilter({
if (deferredRule && !isCompleteRule(deferredRule)) {
const previouslyAppliedRule = currentRules.find((rule) => rule.id === deferredRule.id)
if (previouslyAppliedRule && isCompleteRule(previouslyAppliedRule)) {
const deferredRules = deferredAppliedRulesRef.current
const deferredRules = deferredAppliedRules
if (!deferredRules.has(deferredRule.id)) {
deferredRules.set(deferredRule.id, previouslyAppliedRule)
}
@@ -94,15 +95,15 @@ export function TableFilter({
}
const nextRulesById = new Map(nextRules.map((rule) => [rule.id, rule]))
for (const [id] of deferredAppliedRulesRef.current) {
for (const [id] of deferredAppliedRules) {
const nextRule = nextRulesById.get(id)
if (!nextRule || isCompleteRule(nextRule)) {
deferredAppliedRulesRef.current.delete(id)
deferredAppliedRules.delete(id)
}
}
const appliedRules = nextRules.map((rule) => {
const deferredRule = deferredAppliedRulesRef.current.get(rule.id)
const deferredRule = deferredAppliedRules.get(rule.id)
return deferredRule && !isCompleteRule(rule)
? { ...deferredRule, logicalOperator: rule.logicalOperator }
: rule
@@ -284,7 +284,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
/>
)}
{isRenaming ? (
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<div className='flex size-full min-w-0 items-center px-2 py-[7px]'>
<ColumnTypeIcon
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
@@ -305,7 +305,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
/>
</div>
) : readOnly ? (
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<div className='flex size-full min-w-0 items-center px-2 py-[7px]'>
<ColumnTypeIcon
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
@@ -318,7 +318,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
/>
</div>
) : (
<div className='flex h-full w-full min-w-0 items-center'>
<div className='flex size-full min-w-0 items-center'>
<button
type='button'
className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-hidden'
@@ -704,7 +704,7 @@ export function WorkflowSidebarBody({
</div>
) : workflowState.data ? (
<>
<div className='[&_.react-flow__handle]:hidden! h-full w-full [&_*:active]:cursor-grabbing! [&_*]:cursor-grab!'>
<div className='[&_.react-flow__handle]:hidden! size-full [&_*:active]:cursor-grabbing! [&_*]:cursor-grab!'>
<PreviewWorkflow
workflowState={workflowState.data}
height={160}
@@ -1094,7 +1094,7 @@ export function Chat() {
onClick={handleStopStreaming}
size='sm'
>
<Square className='h-2.5 w-2.5 fill-white text-white dark:fill-black dark:text-black' />
<Square className='size-2.5 fill-white text-white dark:fill-black dark:text-black' />
</ComposerActionButton>
) : (
<ComposerActionButton
@@ -125,7 +125,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
const isDragging = dragCounter > 0
const fileInputRef = useRef<HTMLInputElement>(null)
const attachedFilesRef = useRef<AttachedFile[]>(attachedFiles)
const uploadControllersRef = useRef(new Map<string, AbortController>())
const uploadControllersRef = useRef<Map<string, AbortController> | null>(null)
const uploadControllers = (uploadControllersRef.current ??= new Map())
const updateAttachedFiles = useCallback((update: (files: AttachedFile[]) => AttachedFile[]) => {
const next = update(attachedFilesRef.current)
@@ -138,8 +139,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
*/
useEffect(() => {
return () => {
for (const controller of uploadControllersRef.current.values()) controller.abort()
uploadControllersRef.current.clear()
for (const controller of uploadControllers.values()) controller.abort()
uploadControllers.clear()
attachedFilesRef.current.forEach((f) => revokePreviewUrl(f.previewUrl))
}
}, [])
@@ -227,7 +228,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
})
const controllers = placeholders.map(() => new AbortController())
placeholders.forEach((placeholder, index) => {
uploadControllersRef.current.set(placeholder.id, controllers[index])
uploadControllers.set(placeholder.id, controllers[index])
})
updateAttachedFiles((current) => [...current, ...placeholders])
@@ -273,7 +274,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
revokePreviewUrl(placeholder.previewUrl)
updateAttachedFiles((current) => current.filter((file) => file.id !== placeholder.id))
} finally {
uploadControllersRef.current.delete(placeholder.id)
uploadControllers.delete(placeholder.id)
}
})
},
@@ -313,8 +314,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
*/
const removeFile = useCallback(
(fileId: string) => {
uploadControllersRef.current.get(fileId)?.abort()
uploadControllersRef.current.delete(fileId)
uploadControllers.get(fileId)?.abort()
uploadControllers.delete(fileId)
const file = attachedFilesRef.current.find((f) => f.id === fileId)
revokePreviewUrl(file?.previewUrl)
updateAttachedFiles((current) => current.filter((file) => file.id !== fileId))
@@ -381,8 +382,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
* Clears all attached files and cleanup preview URLs
*/
const clearAttachedFiles = useCallback(() => {
for (const controller of uploadControllersRef.current.values()) controller.abort()
uploadControllersRef.current.clear()
for (const controller of uploadControllers.values()) controller.abort()
uploadControllers.clear()
attachedFilesRef.current.forEach((f) => revokePreviewUrl(f.previewUrl))
updateAttachedFiles(() => [])
}, [updateAttachedFiles])
@@ -394,8 +395,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
*/
const restoreAttachedFiles = useCallback(
(files: AttachedFile[]) => {
for (const controller of uploadControllersRef.current.values()) controller.abort()
uploadControllersRef.current.clear()
for (const controller of uploadControllers.values()) controller.abort()
uploadControllers.clear()
attachedFilesRef.current.forEach((f) => revokePreviewUrl(f.previewUrl))
updateAttachedFiles(() => files)
},
@@ -195,13 +195,13 @@ export function GeneralDeploy({
<Skeleton className='h-[16px] w-[90px]' />
</div>
<div className='h-[260px] w-full overflow-hidden rounded-sm border border-[var(--border)]'>
<Skeleton className='h-full w-full rounded-none' />
<Skeleton className='size-full rounded-none' />
</div>
</div>
<div>
<Skeleton className='mb-[6.5px] h-[16px] w-[60px]' />
<div className='h-[120px] w-full overflow-hidden rounded-sm border border-[var(--border)]'>
<Skeleton className='h-full w-full rounded-none' />
<Skeleton className='size-full rounded-none' />
</div>
</div>
</div>
@@ -244,7 +244,7 @@ export function GeneralDeploy({
>
{workflowToShow ? (
<>
<div className='h-full w-full cursor-default [&_*]:cursor-default!'>
<div className='size-full cursor-default [&_*]:cursor-default!'>
<PreviewWorkflow
workflowState={workflowToShow}
height='100%'
@@ -163,7 +163,8 @@ export function ConnectionBlocks({ connections }: ConnectionBlocksProps) {
const [expandedConnections, setExpandedConnections] = useState<Set<string>>(() => new Set())
const [expandedFieldPaths, setExpandedFieldPaths] = useState<Set<string>>(() => new Set())
const scrollContainerRef = useRef<HTMLDivElement>(null)
const connectionRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const connectionElementsRef = useRef<Map<string, HTMLDivElement> | null>(null)
const connectionRefs = (connectionElementsRef.current ??= new Map())
const { blocks } = useWorkflowStore(
useShallow((state) => ({
@@ -202,7 +203,7 @@ export function ConnectionBlocks({ connections }: ConnectionBlocksProps) {
if (isExpanding) {
setTimeout(() => {
const connectionElement = connectionRefs.current.get(connectionId)
const connectionElement = connectionRefs.get(connectionId)
const scrollContainer = scrollContainerRef.current
if (connectionElement && scrollContainer) {
@@ -289,9 +290,9 @@ export function ConnectionBlocks({ connections }: ConnectionBlocksProps) {
onConnectionDragStart={handleConnectionDragStart}
connectionRef={(el) => {
if (el) {
connectionRefs.current.set(connection.id, el)
connectionRefs.set(connection.id, el)
} else {
connectionRefs.current.delete(connection.id)
connectionRefs.delete(connection.id)
}
}}
mergedSubBlocks={mergedSubBlocks}
@@ -149,8 +149,10 @@ export function ConditionInput({
)
const containerRef = useRef<HTMLDivElement>(null)
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const inputElementsRef = useRef<Map<string, HTMLTextAreaElement> | null>(null)
const inputRefs = (inputElementsRef.current ??= new Map())
const overlayElementsRef = useRef<Map<string, HTMLDivElement> | null>(null)
const overlayRefs = (overlayElementsRef.current ??= new Map())
/**
* Determines if a reference string should be highlighted in the editor.
@@ -818,7 +820,7 @@ export function ConditionInput({
`[data-block-id="${block.id}"] textarea`
) as HTMLTextAreaElement | null
if (textarea) {
inputRefs.current.set(block.id, textarea)
inputRefs.set(block.id, textarea)
}
})
}
@@ -860,8 +862,8 @@ export function ConditionInput({
const newHeight = Math.max(ROUTER_MIN_HEIGHT_PX, startHeight + deltaY)
// Update the textarea height directly for smooth resizing
const textarea = inputRefs.current.get(blockId)
const overlay = overlayRefs.current.get(blockId)
const textarea = inputRefs.get(blockId)
const overlay = overlayRefs.get(blockId)
if (textarea) {
textarea.style.height = `${newHeight}px`
}
@@ -1010,7 +1012,7 @@ export function ConditionInput({
>
<Textarea
ref={(el) => {
if (el) inputRefs.current.set(block.id, el)
if (el) inputRefs.set(block.id, el)
}}
data-router-block-id={block.id}
value={block.value}
@@ -1058,7 +1060,7 @@ export function ConditionInput({
}, 150)
}}
onScroll={(e) => {
const overlay = overlayRefs.current.get(block.id)
const overlay = overlayRefs.get(block.id)
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
@@ -1073,9 +1075,9 @@ export function ConditionInput({
<div
ref={(el) => {
if (el) {
overlayRefs.current.set(block.id, el)
overlayRefs.set(block.id, el)
} else {
overlayRefs.current.delete(block.id)
overlayRefs.delete(block.id)
}
}}
className={cn(
@@ -1161,7 +1163,7 @@ export function ConditionInput({
}}
inputRef={
{
current: inputRefs.current.get(block.id) || null,
current: inputRefs.get(block.id) || null,
} as React.RefObject<HTMLTextAreaElement>
}
/>
@@ -1305,7 +1307,7 @@ export function ConditionInput({
}}
inputRef={
{
current: inputRefs.current.get(block.id) || null,
current: inputRefs.get(block.id) || null,
} as React.RefObject<HTMLTextAreaElement>
}
/>
@@ -24,8 +24,8 @@ interface InputMappingFieldProps {
disabled: boolean
accessiblePrefixes: Set<string> | undefined
inputController: ReturnType<typeof useSubBlockInput>
inputRefs: React.RefObject<Map<string, HTMLInputElement>>
overlayRefs: React.RefObject<Map<string, HTMLDivElement>>
inputRefs: Map<string, HTMLInputElement>
overlayRefs: Map<string, HTMLDivElement>
collapsed: boolean
onToggleCollapse: () => void
workflowSearchHighlight?: ReturnType<typeof getActiveWorkflowSearchHighlight>
@@ -80,8 +80,10 @@ export function InputMapping({
})
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const inputElementsRef = useRef<Map<string, HTMLInputElement> | null>(null)
const inputRefs = (inputElementsRef.current ??= new Map())
const overlayElementsRef = useRef<Map<string, HTMLDivElement> | null>(null)
const overlayRefs = (overlayElementsRef.current ??= new Map())
const workflowId = typeof selectedWorkflowId === 'string' ? selectedWorkflowId : undefined
const { data: workflowState, isLoading } = useWorkflowState(workflowId)
@@ -227,7 +229,7 @@ function InputMappingField({
* @param e - The scroll event
*/
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
const overlay = overlayRefs.current.get(fieldId)
const overlay = overlayRefs.get(fieldId)
if (overlay) {
overlay.scrollLeft = e.currentTarget.scrollLeft
}
@@ -251,7 +253,7 @@ function InputMappingField({
<div className='relative'>
<Input
ref={(el) => {
if (el) inputRefs.current.set(fieldId, el)
if (el) inputRefs.set(fieldId, el)
}}
name='value'
value={value}
@@ -263,7 +265,7 @@ function InputMappingField({
onScroll={(e) => handleScroll(e)}
onPaste={() =>
setTimeout(() => {
const input = inputRefs.current.get(fieldId)
const input = inputRefs.get(fieldId)
input && handleScroll({ currentTarget: input } as any)
}, 0)
}
@@ -277,7 +279,7 @@ function InputMappingField({
/>
<div
ref={(el) => {
if (el) overlayRefs.current.set(fieldId, el)
if (el) overlayRefs.set(fieldId, el)
}}
className={cn(
'absolute inset-0 flex items-center overflow-x-auto bg-transparent px-2 py-1.5 font-sans text-sm',
@@ -308,7 +310,7 @@ function InputMappingField({
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={
{
current: inputRefs.current.get(fieldId) || null,
current: inputRefs.get(fieldId) || null,
} as React.RefObject<HTMLInputElement>
}
/>
@@ -55,8 +55,8 @@ interface TableCellProps {
blockId: string
inputController: ReturnType<typeof useSubBlockInput>
updateCellValue: (rowIndex: number, column: string, newValue: string) => void
inputRefs: React.MutableRefObject<Map<string, HTMLInputElement>>
overlayRefs: React.MutableRefObject<Map<string, HTMLDivElement>>
inputRefs: Map<string, HTMLInputElement>
overlayRefs: Map<string, HTMLDivElement>
accessiblePrefixes: ReturnType<typeof useAccessibleReferencePrefixes>
workspaceId: string
subBlockId: string
@@ -108,7 +108,7 @@ function TableCell({
(newValue) => updateCellValue(rowIndex, column, newValue)
)
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
const overlay = overlayRefs.current.get(cellKey)
const overlay = overlayRefs.get(cellKey)
if (overlay) {
overlay.scrollLeft = e.currentTarget.scrollLeft
}
@@ -138,7 +138,7 @@ function TableCell({
return
}
const nextCellKey = `${rowIndex + 1}-${column}`
const nextInput = inputRefs.current.get(nextCellKey)
const nextInput = inputRefs.get(nextCellKey)
// `isConnected` guards against a stale ref: position-keyed entries can
// outlive a deleted row, and focusing a detached node would steal focus
// from the current cell. A real next row's input is always connected.
@@ -151,8 +151,8 @@ function TableCell({
const syncScrollAfterUpdate = () => {
requestAnimationFrame(() => {
const input = inputRefs.current.get(cellKey)
const overlay = overlayRefs.current.get(cellKey)
const input = inputRefs.get(cellKey)
const overlay = overlayRefs.get(cellKey)
if (input && overlay) {
overlay.scrollLeft = input.scrollLeft
}
@@ -187,8 +187,8 @@ function TableCell({
<div className='relative w-full'>
<input
ref={(el) => {
if (el) inputRefs.current.set(cellKey, el)
else inputRefs.current.delete(cellKey)
if (el) inputRefs.set(cellKey, el)
else inputRefs.delete(cellKey)
}}
type='text'
value={displayValue}
@@ -212,8 +212,8 @@ function TableCell({
/>
<div
ref={(el) => {
if (el) overlayRefs.current.set(cellKey, el)
else overlayRefs.current.delete(cellKey)
if (el) overlayRefs.set(cellKey, el)
else overlayRefs.delete(cellKey)
}}
data-overlay={cellKey}
className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'
@@ -239,7 +239,7 @@ function TableCell({
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(cellKey)}
inputRef={
{
current: inputRefs.current.get(cellKey) || null,
current: inputRefs.get(cellKey) || null,
} as React.RefObject<HTMLInputElement>
}
/>
@@ -255,7 +255,7 @@ function TableCell({
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(cellKey)}
inputRef={
{
current: inputRefs.current.get(cellKey) || null,
current: inputRefs.get(cellKey) || null,
} as React.RefObject<HTMLInputElement>
}
/>
@@ -297,8 +297,10 @@ export function Table({
const value = isPreview ? previewValue : storeValue
// Create refs for input and overlay elements
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const overlayRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const inputElementsRef = useRef<Map<string, HTMLInputElement> | null>(null)
const inputRefs = (inputElementsRef.current ??= new Map())
const overlayElementsRef = useRef<Map<string, HTMLDivElement> | null>(null)
const overlayRefs = (overlayElementsRef.current ??= new Map())
// Memoized template for empty cells for current columns
const emptyCellsTemplate = useMemo(
@@ -383,7 +383,7 @@ interface NestedTagRendererProps {
selectedIndex: number
setSelectedIndex: (index: number) => void
handleTagSelect: (tag: string, blockGroup?: BlockTagGroup) => void
itemRefs: React.RefObject<Map<string, HTMLElement>>
itemRefs: Map<string, HTMLElement>
blocks: Record<string, BlockState>
getMergedSubBlocks: (blockId: string) => Record<string, any>
}
@@ -440,7 +440,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
}}
ref={(el) => {
if (el && currentNestedTag.parentTag) {
itemRefs.current?.set(currentNestedTag.parentTag, el)
itemRefs.set(currentNestedTag.parentTag, el)
}
}}
>
@@ -480,7 +480,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
}}
ref={(el) => {
if (el) {
itemRefs.current?.set(child.fullTag, el)
itemRefs.set(child.fullTag, el)
}
}}
>
@@ -516,7 +516,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
}}
ref={(el) => {
if (el && nestedChild.parentTag) {
itemRefs.current?.set(nestedChild.parentTag, el)
itemRefs.set(nestedChild.parentTag, el)
}
}}
>
@@ -604,7 +604,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
}}
ref={(el) => {
if (el && nestedTag.parentTag) {
itemRefs.current?.set(nestedTag.parentTag, el)
itemRefs.set(nestedTag.parentTag, el)
}
}}
>
@@ -671,7 +671,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
}}
ref={(el) => {
if (el && nestedTag.fullTag) {
itemRefs.current?.set(nestedTag.fullTag, el)
itemRefs.set(nestedTag.fullTag, el)
}
}}
>
@@ -713,7 +713,7 @@ const VariableTagItem: React.FC<{
selectedIndex: number
setSelectedIndex: (index: number) => void
handleTagSelect: (tag: string) => void
itemRefs: React.RefObject<Map<string, HTMLElement>>
itemRefs: Map<string, HTMLElement>
variableInfo: { type: string; id: string } | null
}> = ({
tag,
@@ -739,7 +739,7 @@ const VariableTagItem: React.FC<{
}}
ref={(el) => {
if (el) {
itemRefs.current?.set(tag, el)
itemRefs.set(tag, el)
}
}}
>
@@ -767,7 +767,7 @@ const BlockRootTagItem: React.FC<{
selectedIndex: number
setSelectedIndex: (index: number) => void
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
itemRefs: React.RefObject<Map<string, HTMLElement>>
itemRefs: Map<string, HTMLElement>
group: BlockTagGroup
blockType: string
blockName: string
@@ -796,7 +796,7 @@ const BlockRootTagItem: React.FC<{
}}
ref={(el) => {
if (el) {
itemRefs.current?.set(rootTag, el)
itemRefs.set(rootTag, el)
}
}}
>
@@ -904,7 +904,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
inputRef,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const itemRefs = useRef<Map<string, HTMLElement>>(new Map())
const itemElementsRef = useRef<Map<string, HTMLElement> | null>(null)
const itemRefs = (itemElementsRef.current ??= new Map())
const [nestedPath, setNestedPath] = useState<NestedTag[]>([])
const baseFolderRef = useRef<{
@@ -595,7 +595,7 @@ export function Editor() {
</div>
) : childWorkflowState ? (
<>
<div className='[&_.react-flow__handle]:hidden! h-full w-full [&_*:active]:cursor-grabbing! [&_*]:cursor-grab!'>
<div className='[&_.react-flow__handle]:hidden! size-full [&_*:active]:cursor-grabbing! [&_*]:cursor-grab!'>
<PreviewWorkflow
workflowState={childWorkflowState}
height={160}
@@ -331,7 +331,8 @@ export const Panel = memo(function Panel() {
// Auto-select most recent on first list arrival per workflow, and drop a
// selection that no longer matches anything in the current list (e.g. the
// chat was deleted in another tab).
const autoSelectAttemptedForRef = useRef<Set<string>>(new Set())
const autoSelectAttemptedForRef = useRef<Set<string> | null>(null)
const autoSelectAttemptedFor = (autoSelectAttemptedForRef.current ??= new Set())
useEffect(() => {
// The list query is skipped when the tab is unavailable, so an empty list
// there means "not fetched", not "deleted elsewhere" — clearing on it would
@@ -344,9 +345,9 @@ export const Panel = memo(function Panel() {
}
if (copilotChatId) return
if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return
if (autoSelectAttemptedFor.has(activeWorkflowId)) return
if (copilotChatList.length === 0) return
autoSelectAttemptedForRef.current.add(activeWorkflowId)
autoSelectAttemptedFor.add(activeWorkflowId)
setCopilotChatId(copilotChatList[0].id)
}, [copilotChatList, copilotChatId, activeWorkflowId, isCopilotTabAvailable, setCopilotChatId])
@@ -9,13 +9,13 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
export function useDynamicHandleRefresh() {
const updateNodeInternals = useUpdateNodeInternals()
const blocks = useWorkflowStore((state) => state.blocks)
const previousSignaturesRef = useRef<Map<string, string>>(new Map())
const previousSignaturesRef = useRef<Map<string, string> | null>(null)
const signatures = useMemo(() => collectDynamicHandleTopologySignatures(blocks), [blocks])
useEffect(() => {
const changedBlockIds = getChangedDynamicHandleBlockIds(
previousSignaturesRef.current,
previousSignaturesRef.current ?? new Map(),
signatures
)
previousSignaturesRef.current = signatures
@@ -646,9 +646,11 @@ const WorkflowContent = React.memo(
const connectionCancelledRef = useRef(false)
/** Stores start positions for multi-node drag undo/redo recording. */
const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>(
new Map()
)
const multiNodeDragStartRef = useRef<Map<
string,
{ x: number; y: number; parentId?: string }
> | null>(null)
const multiNodeDragStart = (multiNodeDragStartRef.current ??= new Map())
/** Re-applies diff markers when blocks change after socket rehydration. */
const blocksRef = useRef(blocks)
@@ -2496,13 +2498,14 @@ const WorkflowContent = React.memo(
/** Tracks blocks to pan to after diff updates. */
const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null)
const seenDiffBlocksRef = useRef<Set<string>>(new Set())
const seenDiffBlocksRef = useRef<Set<string> | null>(null)
const seenDiffBlocks = (seenDiffBlocksRef.current ??= new Set())
/** Queues newly changed blocks for viewport panning. */
useEffect(() => {
if (!isDiffReady || !diffAnalysis) {
pendingZoomBlockIdsRef.current = null
seenDiffBlocksRef.current.clear()
seenDiffBlocks.clear()
return
}
@@ -2510,10 +2513,10 @@ const WorkflowContent = React.memo(
const allBlocks = [...(diffAnalysis.new_blocks || []), ...(diffAnalysis.edited_blocks || [])]
for (const id of allBlocks) {
if (!seenDiffBlocksRef.current.has(id)) {
if (!seenDiffBlocks.has(id)) {
newBlocks.add(id)
}
seenDiffBlocksRef.current.add(id)
seenDiffBlocks.add(id)
}
if (newBlocks.size > 0) {
@@ -2786,14 +2789,15 @@ const WorkflowContent = React.memo(
workflows,
])
const blockConfigCache = useRef<Map<string, any>>(new Map())
const blockConfigCacheRef = useRef<Map<string, any> | null>(null)
const blockConfigCache = (blockConfigCacheRef.current ??= new Map())
const getBlockConfig = useCallback((type: string) => {
const cached = blockConfigCache.current.get(type)
const cached = blockConfigCache.get(type)
if (cached) return cached
// Don't cache a miss: custom (deploy-as-block) blocks resolve only once the
// client overlay hydrates, so an early miss must re-resolve on a later render.
const config = getBlock(type)
if (config) blockConfigCache.current.set(type, config)
if (config) blockConfigCache.set(type, config)
return config
}, [])
@@ -2801,7 +2805,7 @@ const WorkflowContent = React.memo(
// CustomBlocksLoader) changes, so renames/icon edits refresh existing nodes.
const { data: customBlocksData } = useCustomBlocks(workspaceId)
useEffect(() => {
for (const cb of customBlocksData ?? []) blockConfigCache.current.delete(cb.type)
for (const cb of customBlocksData ?? []) blockConfigCache.delete(cb.type)
}, [customBlocksData])
const prevBlocksHashRef = useRef<string>('')
@@ -3285,8 +3289,7 @@ const WorkflowContent = React.memo(
// Handle position changes (e.g., from keyboard arrow key movement)
// Update container dimensions when child nodes are moved and persist to backend
// Only persist if not in a drag operation (drag-end is handled by onNodeDragStop)
const isInDragOperation =
getDragStartPosition() !== null || multiNodeDragStartRef.current.size > 0
const isInDragOperation = getDragStartPosition() !== null || multiNodeDragStart.size > 0
const keyboardPositionUpdates: Array<{ id: string; position: { x: number; y: number } }> =
[]
for (const change of workflowChanges) {
@@ -3804,19 +3807,19 @@ const WorkflowContent = React.memo(
// appear in the selected set yet.
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
multiNodeDragStartRef.current.clear()
multiNodeDragStart.clear()
selectedNodes.forEach((n) => {
const block = blocks[n.id]
if (block) {
multiNodeDragStartRef.current.set(n.id, {
multiNodeDragStart.set(n.id, {
x: n.position.x,
y: n.position.y,
parentId: block.data?.parentId,
})
}
})
if (!multiNodeDragStartRef.current.has(node.id)) {
multiNodeDragStartRef.current.set(node.id, {
if (!multiNodeDragStart.has(node.id)) {
multiNodeDragStart.set(node.id, {
x: node.position.x,
y: node.position.y,
parentId: currentParentId ?? undefined,
@@ -3859,7 +3862,7 @@ const WorkflowContent = React.memo(
if (selectedNodes.length > 1) {
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current,
previousPositions: multiNodeDragStart,
})
// Only reparent when an actual drag changed the target container.
@@ -3876,7 +3879,7 @@ const WorkflowContent = React.memo(
setDragStartPosition(null)
setPotentialParentId(null)
multiNodeDragStartRef.current.clear()
multiNodeDragStart.clear()
return
}
@@ -4092,11 +4095,11 @@ const WorkflowContent = React.memo(
})
// Capture positions for undo/redo before applying display changes
multiNodeDragStartRef.current.clear()
multiNodeDragStart.clear()
effectiveNodes.forEach((n) => {
const blk = blocks[n.id]
if (blk) {
multiNodeDragStartRef.current.set(n.id, {
multiNodeDragStart.set(n.id, {
x: n.position.x,
y: n.position.y,
parentId: blk.data?.parentId,
@@ -4236,14 +4239,14 @@ const WorkflowContent = React.memo(
const allNodes = getNodes()
const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes)
collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current,
previousPositions: multiNodeDragStart,
})
executeBatchParentUpdate(nodes, potentialParentId, 'Batch moved selection to new parent')
setDragStartPosition(null)
setPotentialParentId(null)
multiNodeDragStartRef.current.clear()
multiNodeDragStart.clear()
},
[
blocks,
@@ -5023,7 +5026,7 @@ const WorkflowContent = React.memo(
}, [blocksStructureHash, embedded, isWorkflowReady, scheduleEmbeddedFit])
return (
<div className='flex h-full w-full overflow-hidden'>
<div className='flex size-full overflow-hidden'>
<div className='flex min-w-0 flex-1 flex-col'>
<div
ref={canvasContainerRef}
@@ -384,7 +384,7 @@ function ConnectionsSection({
{hasFields && (
<ChevronDown
className={cn(
'h-3.5 w-3.5 shrink-0 transition-transform duration-100',
'size-3.5 shrink-0 transition-transform duration-100',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
@@ -1060,7 +1060,7 @@ function PreviewEditorContent({
const subflowName = block.name || (isLoop ? 'Loop' : 'Parallel')
return (
<div className='relative flex h-full w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='relative flex size-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like subflow header */}
<div className='mx-[-1px] flex shrink-0 items-center gap-2 rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-3 py-1.5'>
<BlockTile blockType={block.type} size='lg' />
@@ -1093,7 +1093,7 @@ function PreviewEditorContent({
if (!blockConfig) {
return (
<div className='flex h-full w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='flex size-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='mx-[-1px] flex items-center gap-2 rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-3 py-1.5'>
<div className='flex size-[18px] items-center justify-center rounded-sm bg-[var(--surface-3)]' />
<span className='text-[var(--text-primary)] text-sm'>
@@ -1150,7 +1150,7 @@ function PreviewEditorContent({
: 'gray'
return (
<div className='relative flex h-full w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='relative flex size-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like editor */}
<div className='mx-[-1px] flex shrink-0 items-center gap-2 rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-3 py-1.5'>
{block.type !== 'note' && <BlockTile blockType={block.type} size='lg' />}
@@ -1374,7 +1374,7 @@ function PreviewEditorContent({
</div>
) : resolvedChildWorkflowState ? (
<>
<div className='[&_.react-flow__handle]:hidden! h-full w-full [&_*:active]:cursor-grabbing! [&_*]:cursor-grab!'>
<div className='[&_.react-flow__handle]:hidden! size-full [&_*:active]:cursor-grabbing! [&_*]:cursor-grab!'>
<PreviewWorkflow
workflowState={resolvedChildWorkflowState}
height={160}
@@ -1624,7 +1624,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
disabled={!permissionsLoading && !canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
<Loader className='size-[16px]' animate />
) : (
<MoreHorizontal className='size-[14px]' />
)}
@@ -1667,7 +1667,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || (!permissionsLoading && !canEdit)}
>
<Plus className='h-[16px] w-[16px]' />
<Plus className='size-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -65,8 +65,8 @@ export default function WorkflowsPage() {
const canCreate = !permissionsLoading && canEdit
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
<div className='flex size-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative size-full flex-1 bg-[var(--bg)]'>
<div className='workflow-container flex h-full items-center justify-center bg-[var(--bg)]'>
{isError ? (
// This is the landing route now, so a failed list fetch would
@@ -180,7 +180,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const socketRef = useRef<Socket | null>(null)
const currentWorkflowIdRef = useRef<string | null>(null)
const explicitWorkflowIdRef = useRef<string | null>(explicitWorkflowId)
const joinControllerRef = useRef(new SocketJoinController())
const joinControllerRef = useRef<SocketJoinController | null>(null)
const joinController = (joinControllerRef.current ??= new SocketJoinController())
const joinRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const authRetryAttemptsRef = useRef(0)
const authRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -210,8 +211,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
operationFailed?: (data: OperationFailedBroadcast) => void
}>({})
const positionUpdateTimeouts = useRef<Map<string, number>>(new Map())
const pendingPositionUpdates = useRef<Map<string, any>>(new Map())
const positionUpdateTimeoutsRef = useRef<Map<string, number> | null>(null)
const positionUpdateTimeouts = (positionUpdateTimeoutsRef.current ??= new Map())
const pendingPositionUpdatesRef = useRef<Map<string, any> | null>(null)
const pendingPositionUpdates = (pendingPositionUpdatesRef.current ??= new Map())
/**
* Presence is high-frequency (cursor frames many times per second) so it lives
@@ -267,11 +270,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
useOperationQueueStore.getState().cancelOperationsForWorkflow(workflowId)
}
positionUpdateTimeouts.current.forEach((timeoutId) => {
positionUpdateTimeouts.forEach((timeoutId) => {
clearTimeout(timeoutId)
})
positionUpdateTimeouts.current.clear()
pendingPositionUpdates.current.clear()
positionUpdateTimeouts.clear()
pendingPositionUpdates.clear()
}, [])
const clearJoinedWorkflowState = useCallback(
@@ -338,7 +341,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
setIsRetryingWorkflowJoin(true)
joinRetryTimeoutRef.current = setTimeout(() => {
joinRetryTimeoutRef.current = null
executeJoinCommands(joinControllerRef.current.retryJoin(command.workflowId))
executeJoinCommands(joinController.retryJoin(command.workflowId))
}, command.delayMs)
logger.warn('Realtime unavailable while joining workflow, scheduling retry', {
@@ -484,7 +487,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
connected: socketInstance.connected,
transport: socketInstance.io.engine?.transport?.name,
})
executeJoinCommands(joinControllerRef.current.setConnected(true))
executeJoinCommands(joinController.setConnected(true))
})
socketInstance.on('disconnect', (reason) => {
@@ -492,7 +495,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
setIsConnecting(false)
setIsRetryingWorkflowJoin(false)
setCurrentSocketId(null)
executeJoinCommands(joinControllerRef.current.setConnected(false))
executeJoinCommands(joinController.setConnected(false))
clearJoinedWorkflowState(false)
if (socketInstance.active) {
@@ -605,7 +608,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
})
socketInstance.on('join-workflow-success', ({ workflowId, presenceUsers }) => {
const result = joinControllerRef.current.handleJoinSuccess(workflowId)
const result = joinController.handleJoinSuccess(workflowId)
if (result.ignored) {
logger.debug(`Ignoring stale join-workflow-success for ${workflowId}`)
@@ -623,7 +626,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
})
socketInstance.on('join-workflow-error', ({ workflowId, error, code, retryable }) => {
const result = joinControllerRef.current.handleJoinError({ workflowId, retryable })
const result = joinController.handleJoinError({ workflowId, retryable })
if (result.ignored) {
logger.debug('Ignoring stale join-workflow-error', {
@@ -668,7 +671,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketInstance.on('workflow-deleted', (data: WorkflowDeletedBroadcast) => {
logger.warn(`Workflow ${data.workflowId} has been deleted`)
const result = joinControllerRef.current.handleWorkflowDeleted(data.workflowId)
const result = joinController.handleWorkflowDeleted(data.workflowId)
if (result.shouldClearCurrent) {
clearJoinedWorkflowState(true)
}
@@ -678,7 +681,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketInstance.on('access-revoked', (data: AccessRevokedBroadcast) => {
logger.warn(`Access to workflow ${data.workflowId} has been revoked`)
const result = joinControllerRef.current.handleAccessRevoked(data.workflowId)
const result = joinController.handleAccessRevoked(data.workflowId)
if (result.shouldClearCurrent) {
clearJoinedWorkflowState(true)
// Surface the same blocked-join UX as a denied join: persistent
@@ -847,7 +850,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
if (workflowId) {
logger.info(`Session expired, rejoining workflow: ${workflowId}`)
executeJoinCommands(joinControllerRef.current.forceRejoinWorkflow(workflowId))
executeJoinCommands(joinController.forceRejoinWorkflow(workflowId))
}
}
})
@@ -931,11 +934,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
return () => {
clearJoinRetryTimeout()
clearAuthRetryTimeout()
positionUpdateTimeouts.current.forEach((timeoutId) => {
positionUpdateTimeouts.forEach((timeoutId) => {
clearTimeout(timeoutId)
})
positionUpdateTimeouts.current.clear()
pendingPositionUpdates.current.clear()
positionUpdateTimeouts.clear()
pendingPositionUpdates.clear()
// Close socket on unmount
if (socketRef.current) {
@@ -986,7 +989,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const requestedWorkflowId = getRequestedWorkflowId()
setBlockedJoinWorkflowId((prev) => (prev && prev !== requestedWorkflowId ? null : prev))
executeJoinCommands(joinControllerRef.current.requestWorkflow(requestedWorkflowId))
executeJoinCommands(joinController.requestWorkflow(requestedWorkflowId))
}, [
explicitWorkflowId,
getRequestedWorkflowId,
@@ -1065,16 +1068,16 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
timestamp: Date.now(),
operationId,
})
pendingPositionUpdates.current.delete(blockId)
const timeoutId = positionUpdateTimeouts.current.get(blockId)
pendingPositionUpdates.delete(blockId)
const timeoutId = positionUpdateTimeouts.get(blockId)
if (timeoutId) {
clearTimeout(timeoutId)
positionUpdateTimeouts.current.delete(blockId)
positionUpdateTimeouts.delete(blockId)
}
return true
}
pendingPositionUpdates.current.set(blockId, {
pendingPositionUpdates.set(blockId, {
workflowId,
operation,
target,
@@ -1083,17 +1086,17 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
operationId,
})
if (!positionUpdateTimeouts.current.has(blockId)) {
if (!positionUpdateTimeouts.has(blockId)) {
const timeoutId = window.setTimeout(() => {
const latestUpdate = pendingPositionUpdates.current.get(blockId)
const latestUpdate = pendingPositionUpdates.get(blockId)
if (latestUpdate) {
socket.emit('workflow-operation', latestUpdate)
pendingPositionUpdates.current.delete(blockId)
pendingPositionUpdates.delete(blockId)
}
positionUpdateTimeouts.current.delete(blockId)
positionUpdateTimeouts.delete(blockId)
}, 33)
positionUpdateTimeouts.current.set(blockId, timeoutId)
positionUpdateTimeouts.set(blockId, timeoutId)
}
return true
}
+2 -3
View File
@@ -1,3 +1,4 @@
import { omit } from '@sim/utils/object'
import { GoogleSlidesIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
@@ -3552,9 +3553,7 @@ const googleSlidesV2SubBlocks = (GoogleSlidesBlock.subBlocks || []).flatMap((sub
const googleSlidesV2Inputs = GoogleSlidesBlock.inputs
? {
...Object.fromEntries(
Object.entries(GoogleSlidesBlock.inputs).filter(([key]) => key !== 'imageSource')
),
...omit(GoogleSlidesBlock.inputs, ['imageSource']),
imageFile: { type: 'json', description: 'Image source (file or URL)' },
}
: {}
+1 -1
View File
@@ -17,7 +17,7 @@ const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 bg-primary transition-all dark:bg-white',
'size-full flex-1 bg-primary transition-all dark:bg-white',
indicatorClassName
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+2 -1
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { airtableConnectorMeta } from '@/connectors/airtable/meta'
@@ -98,7 +99,7 @@ function renderTitle(value: unknown): string | null {
if (value == null) return null
const rendered = typeof value === 'object' ? formatCellValue(value).trim() : String(value).trim()
if (!rendered) return null
return rendered.length > MAX_TITLE_LENGTH ? `${rendered.slice(0, MAX_TITLE_LENGTH)}…` : rendered
return truncate(rendered, MAX_TITLE_LENGTH, '…')
}
/**
@@ -1,5 +1,5 @@
import { createLogger, type Logger } from '@sim/logger'
import { describeError } from '@sim/utils/errors'
import { describeError, toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { isRecordLike, toRecord } from '@sim/utils/object'
import { DrizzleQueryError } from 'drizzle-orm/errors'
@@ -1264,7 +1264,7 @@ export class BlockExecutor {
if (onStreamPromise) {
await onStreamPromise.catch(() => {})
}
throw error instanceof Error ? error : new Error(String(error))
throw toError(error)
}
if (onStreamPromise) {
@@ -322,9 +322,7 @@ function mergeChangedFiles(
*/
function mergePhaseDiffs(createDiff: string | undefined, babysitDiff: string | undefined): string {
const merged = [createDiff, babysitDiff].filter((diff): diff is string => !!diff).join('\n')
return merged.length > MAX_DIFF_BYTES
? `${merged.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]`
: merged
return truncate(merged, MAX_DIFF_BYTES, '\n[diff truncated]')
}
function combineAuthoringAndBabysit(
@@ -554,8 +552,7 @@ async function runCloudAuthoringPi(
let diff: string | undefined
try {
const raw = await runner.readFile(DIFF_PATH)
diff =
raw.length > MAX_DIFF_BYTES ? `${raw.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : raw
diff = truncate(raw, MAX_DIFF_BYTES, '\n[diff truncated]')
} catch {
diff = undefined
}
@@ -297,7 +297,7 @@ function isQuotedGitPath(path: string): boolean {
* the net guard alone let the reported `diff` grow to megabytes across rounds.
*/
function capDiff(text: string): string {
return text.length > MAX_DIFF_BYTES ? `${text.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : text
return truncate(text, MAX_DIFF_BYTES, '\n[diff truncated]')
}
function mergeRoundTotals(total: PiRunTotals, round: PiRunTotals): void {
@@ -7,6 +7,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import type { Client, SFTPWrapper } from 'ssh2'
import {
createSSHConnection,
@@ -129,7 +130,7 @@ export async function captureRepoChanges(
}
const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`)
const out = raw.exitCode === 0 ? raw.stdout : ''
const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out
const diff = truncate(out, maxDiffBytes, '\n[diff truncated]')
return { changedFiles: [...fileSet], diff }
} catch {
return { changedFiles: [], diff: '' }
+1 -1
View File
@@ -714,7 +714,7 @@ export class VariableResolver {
)
return replacement
} catch (error) {
replacementError = error instanceof Error ? error : new Error(String(error))
replacementError = toError(error)
displayResult += match
return match
}
+12 -10
View File
@@ -59,9 +59,11 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
// correlation: the callback echoes it on every result (even failures that resolve no serverId),
// so the tab that started this exact flow matches it while other same-origin tabs — and
// unrelated flows in this tab — ignore the broadcast.
const pendingFlowsRef = useRef<
Map<string, { serverId: string; timeout: number; poll?: number; labelCleared?: boolean }>
>(new Map())
const pendingFlowsRef = useRef<Map<
string,
{ serverId: string; timeout: number; poll?: number; labelCleared?: boolean }
> | null>(null)
const pendingFlows = (pendingFlowsRef.current ??= new Map())
// serverIds with an in-flight `/oauth/start` request — guards a fast double-click from opening
// two popups. Cleared once the request settles, so a later click (to reopen an abandoned
// popup) still starts a fresh flow.
@@ -99,11 +101,11 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
/** End one flow by its `state` nonce, decrementing its server's connecting count exactly once. */
const settleFlow = useCallback(
(state: string) => {
const flow = pendingFlowsRef.current.get(state)
const flow = pendingFlows.get(state)
if (!flow) return
window.clearTimeout(flow.timeout)
if (flow.poll !== undefined) window.clearInterval(flow.poll)
pendingFlowsRef.current.delete(state)
pendingFlows.delete(state)
// The popup-closed poll may have already cleared this flow's label share.
if (!flow.labelCleared) decConnecting(flow.serverId)
},
@@ -114,7 +116,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
const retireFlows = useCallback(
(serverId: string) => {
const states: string[] = []
for (const [state, flow] of pendingFlowsRef.current) {
for (const [state, flow] of pendingFlows) {
if (flow.serverId === serverId) states.push(state)
}
for (const state of states) settleFlow(state)
@@ -123,7 +125,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
)
useEffect(() => {
const pending = pendingFlowsRef.current
const pending = pendingFlows
return () => {
for (const { timeout, poll } of pending.values()) {
window.clearTimeout(timeout)
@@ -143,7 +145,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
const data = event.data as Partial<McpOauthCallbackMessage> | null
if (data?.type !== 'mcp-oauth') return
if (!data.state) return
const flow = pendingFlowsRef.current.get(data.state)
const flow = pendingFlows.get(data.state)
if (!flow) return
const { serverId } = flow
settleFlow(data.state)
@@ -217,7 +219,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
serverId,
timeout: window.setTimeout(() => settleFlow(state), OAUTH_FLOW_TIMEOUT_MS),
}
pendingFlowsRef.current.set(state, flow)
pendingFlows.set(state, flow)
// Best-effort label clear when the user closes the popup: under COOP `closed` can
// misreport true, so this only clears the "Waiting for authorization..." share of
// the count — the flow entry stays registered, and a completion that still arrives
@@ -232,7 +234,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) {
if (!closed) return
if (flow.poll !== undefined) window.clearInterval(flow.poll)
flow.poll = undefined
if (!flow.labelCleared && pendingFlowsRef.current.get(state) === flow) {
if (!flow.labelCleared && pendingFlows.get(state) === flow) {
flow.labelCleared = true
decConnecting(serverId)
}
+2 -3
View File
@@ -6,6 +6,7 @@
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { omit } from '@sim/utils/object'
import {
type InfiniteData,
infiniteQueryOptions,
@@ -2207,9 +2208,7 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
const nextMetadata = prevWidths
? {
...previousDetail.metadata,
columnWidths: Object.fromEntries(
Object.entries(prevWidths).filter(([k]) => k !== stripKey)
),
columnWidths: omit(prevWidths, [stripKey]),
}
: previousDetail.metadata
queryClient.setQueryData<TableDefinition>(tableKeys.detail(tableId), {
+9 -7
View File
@@ -1,6 +1,6 @@
'use client'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
@@ -57,7 +57,8 @@ interface UseSpeechToTextProps {
interface UseSpeechToTextReturn {
isListening: boolean
isSupported: boolean
audioLevelsRef: RefObject<Float32Array>
/** Live input levels, filled in place while listening; the array identity never changes. */
audioLevels: Float32Array
toggleListening: () => void
resetTranscript: () => void
}
@@ -119,7 +120,8 @@ export function useSpeechToText({
const streamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const processorRef = useRef<ScriptProcessorNode | null>(null)
const audioLevelsRef = useRef(new Float32Array(AUDIO_LEVEL_COUNT))
const levelsRef = useRef<Float32Array | null>(null)
const audioLevels = (levelsRef.current ??= new Float32Array(AUDIO_LEVEL_COUNT))
const pcmBufferRef = useRef<Float32Array[]>([])
const sendIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -206,7 +208,7 @@ export function useSpeechToText({
}
pcmBufferRef.current = []
audioLevelsRef.current.fill(0)
audioLevels.fill(0)
isFirstChunkRef.current = true
}, [])
@@ -326,7 +328,7 @@ export function useSpeechToText({
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0)
updateAudioLevels(input, audioLevelsRef.current)
updateAudioLevels(input, audioLevels)
pcmBufferRef.current.push(new Float32Array(input))
}
@@ -395,7 +397,7 @@ export function useSpeechToText({
streamRef.current = null
}
audioLevelsRef.current.fill(0)
audioLevels.fill(0)
const wsToClose = wsRef.current
wsRef.current = null
@@ -439,7 +441,7 @@ export function useSpeechToText({
return {
isListening,
isSupported,
audioLevelsRef,
audioLevels,
toggleListening,
resetTranscript,
}
+1 -1
View File
@@ -34,7 +34,7 @@ beforeEach(() => {
mocks.speech.mockReturnValue({
isListening: false,
isSupported: true,
audioLevelsRef: { current: new Float32Array(5) },
audioLevels: new Float32Array(5),
toggleListening: vi.fn(),
resetTranscript: vi.fn(),
})
+1 -2
View File
@@ -259,8 +259,7 @@ async function initializeOpenTelemetry() {
exporter.export = (spans, resultCallback) => {
origExport(spans, (result) => {
if (result?.code !== 0) {
// eslint-disable-next-line no-console
console.error('[OTEL] exporter export failed', {
logger.error('Exporter export failed', {
endpoint: telemetryConfig.endpoint,
resultCode: result?.code,
error: result?.error?.message,
@@ -113,6 +113,7 @@ function parseMediaFile(input: unknown): {
mimeType?: string
error?: { status: number; message: string }
} {
// utils-lint-allow: narrows to `object` so the RawFileInput assertion compiles; isRecordLike's Record<string, unknown> does not overlap it
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
const raw = input as RawFileInput
if (!raw.name) {
@@ -1,5 +1,6 @@
import { sleep } from '@sim/utils/helpers'
import { isRecordLike } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import type { ReadFileTextResponse } from 'sim/embed'
import { listCatalogTools } from '@/lib/catalog/application/list-tools'
import { readBlockCatalog } from '@/lib/catalog/application/read-block-catalog'
@@ -430,7 +431,7 @@ function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boo
}
function clip(line: string): string {
return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}… [line truncated]` : line
return truncate(line, MAX_LINE_CHARS, '… [line truncated]')
}
function didYouMean(scope: string): string {
@@ -1,4 +1,5 @@
import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema'
import { isRecordLike } from '@sim/utils/object'
type JsonSchemaNode = Record<string, unknown>
@@ -71,7 +72,7 @@ interface PreservedConstraint {
}
function isSchemaNode(value: unknown): value is JsonSchemaNode {
return typeof value === 'object' && value !== null && !Array.isArray(value)
return isRecordLike(value)
}
/**
+3 -2
View File
@@ -13,6 +13,7 @@
* Wired into {@link BlockExecutor} `handleStreamingExecution`.
*/
import { toError } from '@sim/utils/errors'
import {
type AgentStreamEvent,
type AgentStreamFormat,
@@ -404,7 +405,7 @@ export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): Ag
if (drainError && !cancelled) {
closeTextStream(drainError)
throw drainError instanceof Error ? drainError : new Error(String(drainError))
throw toError(drainError)
}
if (cancelled) {
@@ -484,7 +485,7 @@ export function projectStreamingExecutionToByteStream(streamingExec: {
return
}
try {
controller.error(error instanceof Error ? error : new Error(String(error)))
controller.error(toError(error))
} catch {
// already closed/errored
}
+1 -1
View File
@@ -15,7 +15,7 @@ export const useCanvasModeStore = create<CanvasModeState>()(
mode: 'hand',
setMode: (mode) => set({ mode }),
}),
{ name: 'canvas-mode' }
{ name: 'canvas-mode', partialize: (state) => ({ mode: state.mode }) }
),
{ name: 'canvas-mode-store' }
)

Some files were not shown because too many files have changed in this diff Show More