From 2894a3d7e7b1c3dbbc0d2d9afd2b5e27972ac23f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:24:22 -0700 Subject: [PATCH 01/11] improvement(ui): align terminal with the workflow design system, fix row hover states Terminal: - derive log-row block tiles the way the canvas does (role accent for core blocks and subflows, provider colour only for role-less integrations) - compose rows from chipGeometryClass, chipContentLabelClass and disclosureChevronClass instead of re-deriving the pill - align the output tree's greys with the log rows; share ROW_STYLES.nested and BADGE_STYLE instead of duplicating the literals - neutralise value-type badges so red is the only colour in the tree - unify the row/separator gutter; normalise icon sizes to size-[14px] - drop dead flattenEntryTree and the RunningBadge re-exports Hover model (chipVariants, PopoverItem, Combobox, docs sidebar, terminal): - hover paints --surface-hover, one step below the --surface-active a selected row keeps, so a hovered row no longer impersonates the selected one - an active row holds its surface through hover instead of brightening Deploy modal: - move the footer actions onto the Chip family, primary action as Chip variant='primary' to match every other modal footer --- apps/docs/app/global.css | 21 ++- .../docs-layout/sidebar-components.tsx | 32 ++-- .../landing-preview-sidebar.tsx | 21 ++- .../queued-messages/queued-messages.tsx | 4 +- .../deploy-modal/components/mcp/mcp.tsx | 6 +- .../components/deploy-modal/deploy-modal.tsx | 62 +++---- .../entry-block-tile/entry-block-tile.tsx | 40 +++++ .../components/entry-block-tile/index.ts | 1 + .../filter-popover/filter-popover.tsx | 5 +- .../components/terminal/components/index.ts | 3 +- .../components/structured-output.tsx | 62 ++++--- .../components/output-panel/output-panel.tsx | 22 +-- .../components/status-display/index.ts | 2 +- .../status-display/status-display.tsx | 2 +- .../components/terminal/terminal.tsx | 155 ++++++------------ .../[workflowId]/components/terminal/types.ts | 13 +- .../[workflowId]/components/terminal/utils.ts | 39 +++-- .../workspace-header.test.tsx | 8 +- packages/emcn/src/components/chip/chip.tsx | 29 ++-- .../emcn/src/components/combobox/combobox.tsx | 18 +- .../emcn/src/components/popover/popover.tsx | 8 +- 21 files changed, 305 insertions(+), 248 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index e2c10fb937c..c28124e290f 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -518,19 +518,18 @@ html #nd-sidebar button[data-active="true"] { color: var(--text-body) !important; } -/* Hover state — the app's nav Chip hovers to --surface-active. These rules must - carry !important to beat fumadocs' own sidebar styles, which also means they - override the Tailwind hover utilities on the items; keep the two in step. */ +/* Hover state — the app's nav Chip hovers to --surface-hover, one step below the + --surface-active it paints the current page with, so the row under the pointer + never impersonates the selected one. These rules must carry !important to beat + fumadocs' own sidebar styles, which also means they override the Tailwind hover + utilities on the items; keep the two in step. + + The :not([data-active]) guard is what holds the active row at --surface-active + through hover, matching the app's active Chip: hover names what you would open, + and on the page already open there is nothing to name. */ html #nd-sidebar a:not(:has(span.font-mono)):hover:not([data-active="true"]), html #nd-sidebar button:hover:not([data-active="true"]) { - background-color: var(--surface-active) !important; -} - -/* An active item darkens one surface on hover, matching the app's active Chip */ -html #nd-sidebar a[data-active="true"]:not(:has(span.font-mono)):hover, -html #nd-sidebar button[data-active="true"]:hover { - background-color: var(--surface-6) !important; - color: var(--text-body) !important; + background-color: var(--surface-hover) !important; } /* Hide search, platform, and collapse button from sidebar completely */ diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index 6a603fc856e..edf0266562a 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -43,7 +43,10 @@ function isActive(url: string, pathname: string, nested = true): boolean { /** * Rows mirror the app sidebar's chip pill: 30px tall, `rounded-lg`, `px-2`, 14px * at normal weight, `--text-body` at rest AND when active — only the background - * moves, to `--surface-active`, then `--surface-6` when an active row is hovered. + * moves, to `--surface-hover` under the pointer and `--surface-active` when the + * row is the page you are on. The active row keeps that surface through hover, + * matching `chipVariants`: hover says what you would open, and on the page you + * already have open there is nothing to say. * * Height, horizontal padding, weight and color are additionally pinned in * `global.css` (`html #nd-sidebar a…`), which needs `!important` to beat @@ -52,20 +55,25 @@ function isActive(url: string, pathname: string, nested = true): boolean { * the stylesheet is what actually lands on desktop. */ const ITEM_BASE = - 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-body)] text-sm transition-colors hover:bg-[var(--surface-active)]' + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-body)] text-sm transition-colors' const ITEM_ACTIVE_MOBILE = 'bg-[var(--surface-active)]' const ITEM_DESKTOP = 'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2 lg:font-normal lg:text-sm lg:leading-tight' const ITEM_TEXT = 'lg:text-[var(--text-body)]' -const ITEM_HOVER = 'lg:hover:bg-[var(--surface-active)]' -const ITEM_ACTIVE = - 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)] lg:hover:bg-[var(--surface-6)]' +/* + * Unprefixed, and applied only when the row is inactive. It used to live in + * `ITEM_BASE` at every width, which was harmless only while hover and active + * painted the same surface; now that hover is the dimmer of the two, an + * unconditional hover would make the current page fade under the pointer on any + * viewport below `lg`. + */ +const ITEM_HOVER = 'hover:bg-[var(--surface-hover)]' +const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]' const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-normal' -const FOLDER_HOVER = 'lg:hover:bg-[var(--surface-active)]' -const FOLDER_ACTIVE = - 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)] lg:hover:bg-[var(--surface-6)]' +const FOLDER_HOVER = 'hover:bg-[var(--surface-hover)]' +const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]' export function SidebarItem({ item }: { item: Item }) { const pathname = usePathname() @@ -144,7 +152,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac data-active={active} className={cn( 'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', - 'text-[var(--text-body)] hover:bg-[var(--surface-active)]', + 'text-[var(--text-body)]', active && ITEM_ACTIVE_MOBILE, 'lg:block lg:flex-1 lg:rounded-lg lg:px-2 lg:text-sm lg:leading-tight', FOLDER_TEXT, @@ -158,8 +166,8 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac +
- - +
)} @@ -642,18 +640,13 @@ export function DeployModal({
{chatExists && ( - + )} - +
)} @@ -676,9 +669,8 @@ export function DeployModal({
- + - + {mcpToolSaveDisabledReason && ( @@ -888,10 +880,14 @@ function GeneralFooter({ {status}
- +
) @@ -901,14 +897,18 @@ function GeneralFooter({ {status}
- + {(needsRedeployment || isDeploymentSettling) && ( - + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx new file mode 100644 index 00000000000..0b2de78e43c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx @@ -0,0 +1,40 @@ +'use client' + +import { memo } from 'react' +import { WorkflowTypeIcon } from '@sim/workflow-renderer' +import clsx from 'clsx' +import { + getBlockColor, + getBlockIcon, + getEntryAccentType, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' +import { getTileIconColorClass } from '@/blocks/icon-color' + +export interface EntryBlockTileProps { + blockType: string +} + +/** + * A log row's block tile. + * + * Accented through the canvas's own derivation, so a row reads as the card it + * ran from rather than as a second, unrelated colour scheme. + */ +export const EntryBlockTile = memo(function EntryBlockTile({ blockType }: EntryBlockTileProps) { + const BlockIcon = getBlockIcon(blockType) + const bgColor = getBlockColor(blockType) + const accentType = getEntryAccentType(blockType) + + if (BlockIcon && accentType) { + return + } + + return ( +
+ {BlockIcon && } +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts new file mode 100644 index 00000000000..6617a4df600 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts @@ -0,0 +1 @@ +export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx index 3ba40426fd0..e3553338095 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx @@ -13,11 +13,11 @@ import { } from '@sim/emcn' import { ListFilter } from '@sim/emcn/icons' import clsx from 'clsx' +import { EntryBlockTile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile' import type { BlockInfo, TerminalFilters, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' -import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' /** * Props for the FilterPopover component @@ -94,7 +94,6 @@ export const FilterPopover = memo(function FilterPopover({ Blocks {uniqueBlocks.map((block) => { - const BlockIcon = getBlockIcon(block.blockType) const isSelected = filters.blockIds.has(block.blockId) return ( @@ -104,7 +103,7 @@ export const FilterPopover = memo(function FilterPopover({ showCheck={isSelected} onClick={() => toggleBlock(block.blockId)} > - {BlockIcon && } + {block.blockName} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts index b230b8196ad..2fdcd094f91 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts @@ -1,5 +1,6 @@ +export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile' export { FilterPopover, type FilterPopoverProps } from './filter-popover' export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' export { OutputPanel, type OutputPanelProps } from './output-panel' -export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' +export { StatusDisplay, type StatusDisplayProps } from './status-display' export { ToggleButton, type ToggleButtonProps } from './toggle-button' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index 6a4c917bd39..07d8159066b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -11,7 +11,7 @@ import { useRef, useState, } from 'react' -import { Badge, ChevronDown, cn } from '@sim/emcn' +import { Badge, ChevronDown, chipContentGap, cn, disclosureChevronClass } from '@sim/emcn' import { useVirtualizer } from '@tanstack/react-virtual' import { isUserFileDisplayMetadata } from '@/lib/core/utils/user-file' import { @@ -19,9 +19,13 @@ import { type LargeArrayManifest, } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { + BADGE_STYLE, + ROW_STYLES, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object' -type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red' +type BadgeVariant = 'gray-secondary' | 'red' interface NodeEntry { key: string @@ -51,28 +55,42 @@ const CONFIG = { VIRTUALIZATION_THRESHOLD: 200, } as const -const BADGE_VARIANTS: Record = { - string: 'green', - number: 'blue', - boolean: 'orange', - array: 'purple', - null: 'gray', - undefined: 'gray', - object: 'gray', -} as const +/** + * The tag a value's type is announced with. + * + * One neutral form for every type, because the hue was never carrying the + * information: the tag already spells the type out, and the value beside it + * shows it again through quotes, digits or brackets. A colour per type was a + * third encoding of a fact stated twice — and it was the expensive one, since + * four saturated tags on every row left the one colour that means something in + * this pane, the red of a failed value, competing with decoration instead of + * being the only thing that stands out. Red stays; nothing else is coloured. + * + * Built from `--surface-4` / `--text-secondary` rather than the bespoke + * `--badge-*` palette, so the tag ages with the platform's surface tokens. + */ +const VALUE_TYPE_BADGE_VARIANT: BadgeVariant = 'gray-secondary' /** - * Styling constants matching the original non-virtualized implementation. + * Styling constants for the output tree. + * + * A value row is the same chip as a log row on the left, and reuses the same + * shared constants — the two panes sit side by side, so a key here reading in a + * different grey from a block name there is immediately visible. + * + * `row` cannot take `chipGeometryClass` wholesale: a wrapped value grows past + * one line, so it needs `min-h-[30px]` where a chip is fixed at `h-[30px]`. The + * rest of the pill — radius, padding, gap, text size — is the chip's. */ const STYLES = { - row: 'group flex min-h-[30px] cursor-pointer items-center gap-2 rounded-lg px-2 -mx-2 hover-hover:bg-[var(--surface-active)]', - chevron: 'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100', - keyName: 'text-sm text-[var(--text-primary)]', - badge: 'rounded-sm px-1 py-[0px] text-xs', - summary: 'text-sm text-[var(--text-secondary)]', - indent: 'mt-0.5 ml-[3px] flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-[9px]', - value: 'min-w-0 py-0.5 text-sm text-[var(--text-primary)]', - emptyValue: 'py-0.5 text-sm text-[var(--text-secondary)]', + row: `group flex min-h-[30px] cursor-pointer items-center ${chipContentGap} -mx-2 rounded-lg px-2 text-sm transition-colors hover-hover:bg-[var(--surface-hover)]`, + chevron: disclosureChevronClass, + keyName: 'text-sm text-[var(--text-body)]', + badge: BADGE_STYLE, + summary: 'text-sm text-[var(--text-muted)]', + indent: ROW_STYLES.nested, + value: 'min-w-0 py-0.5 text-sm text-[var(--text-body)]', + emptyValue: 'py-0.5 text-sm text-[var(--text-muted)]', matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', currentMatchHighlight: 'bg-orange-400', } as const @@ -399,7 +417,7 @@ const StructuredNode = memo(function StructuredNode({ [displayValue, isPrimitiveValue] ) - const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type] + const badgeVariant = isError ? 'red' : VALUE_TYPE_BADGE_VARIANT const valueText = isPrimitiveValue ? formatPrimitive(displayValue) : '' const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES @@ -654,7 +672,7 @@ function VirtualizedRow({ const paddingLeft = CONFIG.BASE_PADDING + row.depth * CONFIG.INDENT_PER_LEVEL if (row.type === 'header') { - const badgeVariant = row.isError ? 'red' : BADGE_VARIANTS[row.valueType] + const badgeVariant = row.isError ? 'red' : VALUE_TYPE_BADGE_VARIANT return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 5382e0fdd89..8582f06c58e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -346,7 +346,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Close search' className='!p-1.5 -m-1.5' > - + @@ -362,7 +362,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Search in output' className='!p-1.5 -m-1.5' > - + @@ -380,7 +380,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Component Playground' className='!p-1.5 -m-1.5' > - + @@ -399,9 +399,9 @@ export const OutputPanel = React.memo(function OutputPanel({ className='!p-1.5 -m-1.5' > {showCopySuccess ? ( - + ) : ( - + )} @@ -419,7 +419,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Export console CSV' className='!p-1.5 -m-1.5' > - + @@ -434,7 +434,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Clear console' className='!p-1.5 -m-1.5' > - + @@ -451,7 +451,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Terminal options' className='!p-1.5 -m-1.5' > - + - +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts index 0bc435a9a77..3078939f128 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts @@ -1 +1 @@ -export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' +export { StatusDisplay, type StatusDisplayProps } from './status-display' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx index 8388edc2acb..8c896389a08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx @@ -7,7 +7,7 @@ import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon /** * Running badge component - displays a consistent "Running" indicator */ -export const RunningBadge = memo(function RunningBadge() { +const RunningBadge = memo(function RunningBadge() { return ( Running diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 803e5024a4d..8a7dabee030 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -5,6 +5,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, ChevronDown, + chipContentGap, + chipContentLabelClass, + cn, + disclosureChevronClass, handleKeyboardActivation, Popover, PopoverContent, @@ -22,6 +26,7 @@ import { sendMothershipMessage } from '@/lib/mothership/events' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + EntryBlockTile, LogRowContextMenu, OutputPanel, StatusDisplay, @@ -39,8 +44,6 @@ import { type ExecutionGroup, flattenBlockEntriesOnly, flattenVisibleExecutionRows, - getBlockColor, - getBlockIcon, groupEntriesByExecution, isEventFromEditableElement, type NavigableBlockEntry, @@ -48,7 +51,6 @@ import { type VisibleTerminalRow, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { getTileIconColorClass } from '@/blocks/icon-color' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import type { ConsoleEntry } from '@/stores/terminal' import { @@ -70,6 +72,16 @@ const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH.MIN const MAX_TREE_DEPTH = 50 +/** + * Gutter every log row and run separator is inset by. + * + * One value on both edges: a row's selected fill and the separator above it are + * the two widest things in the list, and a separator that stops short of the + * fill it sits against reads as a ragged right edge. `pl-[10px]` on the row and + * `mx-[4px]` on the separator used to disagree by exactly those 4px. + */ +const LOG_ROW_GUTTER_CLASS = 'px-[10px]' + function hasMatchInTree( nodes: EntryNode[], predicate: (e: ConsoleEntry) => boolean, @@ -96,22 +108,16 @@ const BlockRow = memo(function BlockRow({ isSelected: boolean onSelect: (entry: ConsoleEntry) => void }) { - const BlockIcon = getBlockIcon(entry.blockType) const hasError = Boolean(entry.error) const isRunning = Boolean(entry.isRunning) const isCanceled = Boolean(entry.isCanceled) - const bgColor = getBlockColor(entry.blockType) return (
{ e.stopPropagation() onSelect(entry) @@ -120,25 +126,13 @@ const BlockRow = memo(function BlockRow({ handleKeyboardActivation(event, () => onSelect(entry), { stopPropagation: true }) } > -
-
- {BlockIcon && ( - - )} -
- +
+ + {entry.blockName}
- + { e.stopPropagation() onToggle() }} onKeyDown={(event) => handleKeyboardActivation(event, onToggle, { stopPropagation: true })} > -
- +
+ {iterationLabel} {hasChildren && ( - + )}
0 @@ -287,7 +266,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
{ e.stopPropagation() onToggleNode(nodeId) @@ -296,36 +275,19 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ handleKeyboardActivation(event, () => onToggleNode(nodeId), { stopPropagation: true }) } > -
-
- {BlockIcon && ( - - )} -
- +
+ + {displayName} {hasChildren && ( - + )}
0 @@ -402,11 +362,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
{ e.stopPropagation() if (!isSelected) onSelectEntry(entry) @@ -423,36 +379,19 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({ ) } > -
-
- {BlockIcon && ( - - )} -
- +
+ + {entry.blockName} {hasChildren && ( - + )}
-
+
+
) } return ( -
-
+
+
{sortDirection === 'desc' ? ( - + ) : ( - + )} @@ -1350,7 +1289,7 @@ export const Terminal = memo(function Terminal() { aria-label='Component Playground' className='!p-1.5 -m-1.5' > - + @@ -1370,7 +1309,7 @@ export const Terminal = memo(function Terminal() { aria-label='Export console CSV' className='!p-1.5 -m-1.5' > - + @@ -1385,7 +1324,7 @@ export const Terminal = memo(function Terminal() { aria-label='Clear console' className='!p-1.5 -m-1.5' > - + @@ -1405,7 +1344,7 @@ export const Terminal = memo(function Terminal() { aria-label='Terminal options' className='!p-1.5 -m-1.5' > - + = new Set(Object.keys(SPECIAL_BLOCK_COLORS)) + +/** + * The type a log row's tile takes its accent from, or `undefined` when the row + * must fall back to the block's own provider colour. + * + * Same rule the block toolbar applies, so a block is accented identically + * wherever it is listed: core blocks and subflows always take the canvas role + * accent; an integration takes one only when it has a role, and otherwise keeps + * the provider colour it wears on the card. + */ +export function getEntryAccentType(blockType: string): string | undefined { + if (SYNTHETIC_BLOCK_TYPES.has(blockType)) return undefined + const blockConfig = getBlock(blockType) + if (blockConfig && blockConfig.category !== 'blocks') { + return hasWorkflowTypeRole(blockType) ? blockType : undefined + } + return blockType +} + /** * Determines if a keyboard event originated from a text-editable element */ @@ -645,20 +670,6 @@ export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup return result } -/** - * Flattens entry tree into display order for keyboard navigation - */ -export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] { - const result: ConsoleEntry[] = [] - for (const node of nodes) { - result.push(node.entry) - if (node.children.length > 0) { - result.push(...flattenEntryTree(node.children)) - } - } - return result -} - /** * Block entry with parent tracking for navigation */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index 267e66b9275..c420e8910ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -122,9 +122,11 @@ function row(name: string): HTMLElement { /** * Whether a row is painted with the persistent active fill. * - * Matches an exact class token, never a substring: the inactive chip carries - * `hover-hover:bg-[var(--surface-active)]`, which *contains* the active class, so a - * substring check reports every row as marked. + * Matches an exact class token, never a substring. The inactive chip now hovers to + * `--surface-hover`, so it no longer carries the active class as a substring — but + * keep the token match: hover and active are one token apart by design, and a + * substring check would silently start reporting every row as marked if they ever + * converge again. */ function isMarked(name: string): boolean { return [...row(name).querySelectorAll('*')].some((el) => diff --git a/packages/emcn/src/components/chip/chip.tsx b/packages/emcn/src/components/chip/chip.tsx index 06a2a3064dd..848cc2ea45b 100644 --- a/packages/emcn/src/components/chip/chip.tsx +++ b/packages/emcn/src/components/chip/chip.tsx @@ -27,16 +27,16 @@ import { * - `chipVariants({...})` → any other element (`
`, `` inner, etc.) * * @remarks - * The implicit **default** variant is the bare pill — transparent, `--surface-active` on hover. Omit `variant` + * The implicit **default** variant is the bare pill — transparent, `--surface-hover` on hover. Omit `variant` * to get it (shadcn-style); never write `variant='default'`. Named variants: - * `filled` (`--surface-5` light / `--surface-4` dark fill, `--surface-active` hover) — a borderless surface reserved for + * `filled` (`--surface-5` light / `--surface-4` dark fill, `--surface-hover` hover) — a borderless surface reserved for * chip FIELDS/TRIGGERS ({@link ChipInput}/{@link ChipDropdown}/{@link ChipSelect}/{@link ChipDatePicker}), **never `Chip` * itself**; those triggers add the `--border-1` outline themselves via `TRIGGER_BORDER_CLASS`; * `primary` (inverse surface), `destructive` (error-token surface), `border-shadow` (raised card-like surface), * `border` (the `border-shadow` shadow ring on a transparent surface — an outline drawn purely via box-shadow, * no CSS border, no fill). - * `active` renders the default/filled chip in its selected state — `--surface-active` at rest, one surface darker - * (`--surface-6`) on hover. `fullWidth` swaps `inline-flex` for block-level `flex`. + * `active` renders the default/filled chip in its selected state — `--surface-active`, held through hover. + * `fullWidth` swaps `inline-flex` for block-level `flex`. * * The chip carries NO outer margin — spacing between chips belongs to the parent, as a `gap`. It used to ship a * default `mx-0.5` "cluster margin" with a `flush` prop to switch it off, which meant a chip's visual box was not @@ -44,10 +44,19 @@ import { * could never close past the margins. Do not reintroduce it. * * The default/filled hover lives in `active`-keyed compound variants (not the base variant string) so the - * rest/hover classes are mutually exclusive — a chip renders exactly ONE `hover-hover:bg-*`. This keeps raw + * rest/hover classes are mutually exclusive — a chip renders AT MOST ONE `hover-hover:bg-*`. This keeps raw * `chipVariants({...})` consumers identical to `cn(chipVariants({...}))` ones; folding the non-active hover back * into the variant string would emit two conflicting hover classes that only `cn`'s tailwind-merge resolves, * silently diverging raw consumers (e.g. an active row that darkens with `Chip` but not with raw `chipVariants`). + * + * Two states, two surfaces, and the token names say which is which: hovering is `--surface-hover`, being + * selected is the one step stronger `--surface-active`. **An active chip takes no hover class at all.** + * + * Both halves of that matter. Hover used to land on `--surface-active` as well, so a row the pointer merely + * passed over was painted exactly like the selected one and a list briefly appeared to have two selections. + * And an active chip used to brighten to `--surface-6`, which read as the selection *changing* under the + * cursor — but hover answers "what would I act on", and on the row already selected the answer is nothing + * new. Selection is a persistent fact about the list; a pointer passing over it is not an event. */ const chipVariants = cva( `group cursor-pointer ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`, @@ -62,7 +71,7 @@ const chipVariants = cva( 'border-shadow': 'bg-[var(--surface-2)] shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-3)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)] dark:hover-hover:bg-[var(--surface-4)]', border: - 'shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-active)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]', + 'shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-hover)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]', }, active: { true: '', false: '' }, fullWidth: { true: 'flex', false: 'inline-flex' }, @@ -71,22 +80,22 @@ const chipVariants = cva( { variant: 'default', active: false, - className: 'hover-hover:bg-[var(--surface-active)]', + className: 'hover-hover:bg-[var(--surface-hover)]', }, { variant: 'default', active: true, - className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]', + className: 'bg-[var(--surface-active)]', }, { variant: 'filled', active: false, - className: 'hover-hover:bg-[var(--surface-active)]', + className: 'hover-hover:bg-[var(--surface-hover)]', }, { variant: 'filled', active: true, - className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]', + className: 'bg-[var(--surface-active)]', }, ], defaultVariants: { variant: 'default', active: false, fullWidth: false }, diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 41ccc3b7772..1b72f8a0b4e 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -841,7 +841,12 @@ const Combobox = memo( className={cn( 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans', size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', - 'hover-hover:bg-[var(--surface-active)]', + /* No hover class: `onMouseEnter` moves the highlight to this + row, so `isHighlighted` already paints it. A hover class on + top would be a second, differently-timed answer to the same + question — and once it resolves to a surface other than + `--surface-active`, a moused row and a keyboard-arrowed row + stop matching. */ (isHighlighted || isSelected) && 'bg-[var(--surface-active)]', option.disabled && 'cursor-not-allowed opacity-50' )} @@ -881,8 +886,12 @@ const Combobox = memo( className={cn( 'relative flex cursor-pointer select-none items-center rounded-sm px-1.5 font-sans', size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', - 'hover-hover:bg-[var(--surface-active)]', - !multiSelectValues?.length && 'bg-[var(--surface-active)]' + /* This row clears the highlight rather than taking it, so unlike the + option rows it does carry its own hover — as the dimmer surface, + and only while it is not already the selected "all" state. */ + !multiSelectValues?.length + ? 'bg-[var(--surface-active)]' + : 'hover-hover:bg-[var(--surface-hover)]' )} > @@ -915,7 +924,8 @@ const Combobox = memo( className={cn( 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans', size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', - 'hover-hover:bg-[var(--surface-active)]', + /* See above: `onMouseEnter` owns the highlight, so the row needs + no hover class of its own. */ (isHighlighted || isSelected) && 'bg-[var(--surface-active)]', option.disabled && 'cursor-not-allowed opacity-50' )} diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx index f3406494c0e..40bd69c5df8 100644 --- a/packages/emcn/src/components/popover/popover.tsx +++ b/packages/emcn/src/components/popover/popover.tsx @@ -116,7 +116,13 @@ const STYLES = { states: { default: { active: 'bg-[var(--surface-active)]', - hover: 'hover-hover:bg-[var(--surface-active)]', + /* + * One step below `active`, like `chipVariants`. `getItemStateClasses` + * returns active OR hover and never both, so a checked item already holds + * its surface through hover; this only stops an unchecked item under the + * pointer from being painted the same as the checked one. + */ + hover: 'hover-hover:bg-[var(--surface-hover)]', }, inverted: { active: From 559bebe91f379550d87a1e51d70bb9f118c7233b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:45:11 -0700 Subject: [PATCH 02/11] improvement(emcn): extract the row-state surface pair, simplify the terminal rows Review follow-ups from /simplify and /cleanup: - add chipHoverSurfaceClass / chipActiveSurfaceClass to chip-chrome as the one home for the two-surface row model, and route chipVariants, PopoverItem, Combobox, the docs sidebar, the landing preview and queued messages through them instead of restating the literals - terminal ROW_STYLES now renders chipVariants rather than re-deriving its output, and the four rows share content/label/status classes resolved once - structured-output composes chipGeometryClass with an h-auto override rather than restating four of its literals - getEntryAccentType collapses to one expression, dropping the SYNTHETIC_BLOCK types coupling - collapse the chip compound variants to two array-matched entries - deploy modal: hoist the shared loader adornment, size it with chipContentIconClass so it matches every other chip icon - landing preview drops its --c-active/--c-hover inline aliases for the tokens - trim the rationale to one canonical copy with cross-references, and convert the block comments on declarations to TSDoc --- .claude/rules/emcn-components.md | 2 +- apps/docs/app/global.css | 13 ++--- .../docs-layout/sidebar-components.tsx | 55 ++++++------------- .../landing-preview-sidebar.tsx | 27 ++++----- .../queued-messages/queued-messages.tsx | 9 ++- .../components/deploy-modal/deploy-modal.tsx | 9 ++- .../entry-block-tile/entry-block-tile.tsx | 13 ++--- .../components/structured-output.tsx | 45 +++++++-------- .../components/terminal/terminal.tsx | 49 ++++++----------- .../[workflowId]/components/terminal/types.ts | 19 ++++--- .../[workflowId]/components/terminal/utils.ts | 23 +++----- .../emcn/src/components/chip/chip-chrome.ts | 15 +++++ packages/emcn/src/components/chip/chip.tsx | 38 +++---------- .../emcn/src/components/combobox/combobox.tsx | 31 ++++++----- packages/emcn/src/components/index.ts | 2 + .../emcn/src/components/popover/popover.tsx | 14 ++--- 16 files changed, 153 insertions(+), 211 deletions(-) diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 5ca3d2a8dcb..c5548d327df 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -11,7 +11,7 @@ Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources: -- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, and `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. +- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells), and the row-state pair `chipHoverSurfaceClass` / `chipActiveSurfaceClass` (hover vs. selected — mutually exclusive, so a selected row holds its surface through hover; every hand-rolled row imports these rather than restating the literals). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. - **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity. Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border-1` border. diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index c28124e290f..6373ec36d75 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -518,15 +518,10 @@ html #nd-sidebar button[data-active="true"] { color: var(--text-body) !important; } -/* Hover state — the app's nav Chip hovers to --surface-hover, one step below the - --surface-active it paints the current page with, so the row under the pointer - never impersonates the selected one. These rules must carry !important to beat - fumadocs' own sidebar styles, which also means they override the Tailwind hover - utilities on the items; keep the two in step. - - The :not([data-active]) guard is what holds the active row at --surface-active - through hover, matching the app's active Chip: hover names what you would open, - and on the page already open there is nothing to name. */ +/* Hover state — emcn's two-surface row model (see `chipHoverSurfaceClass`). + These rules must carry !important to beat fumadocs' own sidebar styles, which + also means they override the Tailwind hover utilities on the items; keep the + two in step. */ html #nd-sidebar a:not(:has(span.font-mono)):hover:not([data-active="true"]), html #nd-sidebar button:hover:not([data-active="true"]) { background-color: var(--surface-hover) !important; diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index edf0266562a..c3968b021f2 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -1,6 +1,7 @@ 'use client' import { type ReactNode, useState } from 'react' +import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '@sim/emcn' import { ChevronRight } from '@sim/emcn/icons' import type { Folder, Item, Separator } from 'fumadocs-core/page-tree' import { useSidebar } from 'fumadocs-ui/components/sidebar/base' @@ -43,10 +44,7 @@ function isActive(url: string, pathname: string, nested = true): boolean { /** * Rows mirror the app sidebar's chip pill: 30px tall, `rounded-lg`, `px-2`, 14px * at normal weight, `--text-body` at rest AND when active — only the background - * moves, to `--surface-hover` under the pointer and `--surface-active` when the - * row is the page you are on. The active row keeps that surface through hover, - * matching `chipVariants`: hover says what you would open, and on the page you - * already have open there is nothing to say. + * moves, on the two-surface model — see emcn's `chipHoverSurfaceClass`. * * Height, horizontal padding, weight and color are additionally pinned in * `global.css` (`html #nd-sidebar a…`), which needs `!important` to beat @@ -56,44 +54,32 @@ function isActive(url: string, pathname: string, nested = true): boolean { */ const ITEM_BASE = 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-body)] text-sm transition-colors' -const ITEM_ACTIVE_MOBILE = 'bg-[var(--surface-active)]' +const ITEM_ACTIVE_MOBILE = chipActiveSurfaceClass const ITEM_DESKTOP = 'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2 lg:font-normal lg:text-sm lg:leading-tight' const ITEM_TEXT = 'lg:text-[var(--text-body)]' -/* - * Unprefixed, and applied only when the row is inactive. It used to live in - * `ITEM_BASE` at every width, which was harmless only while hover and active - * painted the same surface; now that hover is the dimmer of the two, an - * unconditional hover would make the current page fade under the pointer on any - * viewport below `lg`. +/** + * Unprefixed, and applied only to inactive rows — an unconditional hover in + * `ITEM_BASE` would fade the current page under the pointer below `lg`. */ -const ITEM_HOVER = 'hover:bg-[var(--surface-hover)]' +const ITEM_HOVER = chipHoverSurfaceClass const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]' const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-normal' -const FOLDER_HOVER = 'hover:bg-[var(--surface-hover)]' +const FOLDER_HOVER = chipHoverSurfaceClass const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]' +const itemClass = (active: boolean) => + cn(ITEM_BASE, ITEM_DESKTOP, ITEM_TEXT, active ? cn(ITEM_ACTIVE_MOBILE, ITEM_ACTIVE) : ITEM_HOVER) + export function SidebarItem({ item }: { item: Item }) { const pathname = usePathname() const { prefetch } = useSidebar() const active = isActive(item.url, pathname, false) return ( - + {item.name} ) @@ -127,14 +113,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac href={item.index.url} prefetch={prefetch} data-active={active} - className={cn( - ITEM_BASE, - active && ITEM_ACTIVE_MOBILE, - ITEM_DESKTOP, - ITEM_TEXT, - !active && ITEM_HOVER, - active && ITEM_ACTIVE - )} + className={itemClass(active)} > {item.name} @@ -153,11 +132,9 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac className={cn( 'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', 'text-[var(--text-body)]', - active && ITEM_ACTIVE_MOBILE, 'lg:block lg:flex-1 lg:rounded-lg lg:px-2 lg:text-sm lg:leading-tight', FOLDER_TEXT, - !active && FOLDER_HOVER, - active && FOLDER_ACTIVE + active ? cn(ITEM_ACTIVE_MOBILE, FOLDER_ACTIVE) : FOLDER_HOVER )} > {item.name} @@ -166,8 +143,8 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac @@ -159,11 +161,11 @@ export const WorkflowControls = memo(function WorkflowControls() { @@ -177,10 +179,10 @@ export const WorkflowControls = memo(function WorkflowControls() { From bda4d980edd216ded9ec268f8e59bf78a6487f8c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:50:18 -0700 Subject: [PATCH 04/11] improvement(workflow): give the canvas-mode chevron the same treatment as its siblings It was the only control in the cluster with no hover fill and a different rest colour (--text-muted against the others' --text-secondary), so it read fainter and behaved differently under the pointer. It is also a disclosure chevron, so it now uses disclosureChevronClass instead of a hand-rolled duration-100 copy, and a real 20px box instead of the !p-1.5 override plus -m-1 hit-area hack. --- .../components/workflow-controls/workflow-controls.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index 1eb49613caa..ccd03e4b7af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -7,6 +7,7 @@ import { Cursor, chipHoverSurfaceClass, cn, + disclosureChevronClass, Hand, Popover, PopoverAnchor, @@ -108,9 +109,12 @@ export const WorkflowControls = memo(function WorkflowControls() { )} -
From aa9e0c8ed4cb7168c54964feca30754a0cc55bc5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 23:57:07 -0700 Subject: [PATCH 05/11] test(terminal): lock the log-row accent rule against the block toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getEntryAccentType encodes a cross-surface rule — a block must be accented the same way in the terminal as in the block toolbar — and nothing enforced it. The table covers every branch: core blocks mapped and unmapped, role-bearing and role-less integrations and triggers, the config-less subflows, and the synthesized error/validation/cancelled rows that must keep their status fill. Verified failing: reverting the guard to an unconditional return reds two of the four cases. --- .../components/terminal/utils.test.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts index ee07dba42dd..e1b8c5f0f72 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@/blocks', () => ({ getBlock: vi.fn().mockReturnValue(null), @@ -17,11 +17,13 @@ vi.mock('@/stores/constants', () => ({ TERMINAL_BLOCK_COLUMN_WIDTH: { MIN: 120, DEFAULT: 200, MAX: 400 }, })) +import { getBlock } from '@/blocks' import type { ConsoleEntry } from '@/stores/terminal' import { buildEntryTree, type EntryNode, flattenVisibleExecutionRows, + getEntryAccentType, groupEntriesByExecution, } from './utils' @@ -856,3 +858,49 @@ describe('flattenVisibleExecutionRows', () => { expect(rowsExpanded[1].depth).toBe(1) }) }) + +describe('getEntryAccentType', () => { + const mockedGetBlock = vi.mocked(getBlock) + + afterEach(() => { + mockedGetBlock.mockReturnValue(null as never) + }) + + function withCategory(category: string) { + mockedGetBlock.mockReturnValue({ category } as never) + } + + it('accents a core block by its type, mapped or not', () => { + withCategory('blocks') + expect(getEntryAccentType('agent')).toBe('agent') + /* + * An unmapped core block still takes the accent path and lands on `neutral`, + * which is exactly what the block toolbar does for a newly added one — the + * two surfaces must not disagree while the role map catches up. + */ + expect(getEntryAccentType('brand_new_core_block')).toBe('brand_new_core_block') + }) + + it('accents a non-core block only when it carries a canvas role', () => { + withCategory('tools') + expect(getEntryAccentType('table')).toBe('table') + // A role-less integration keeps its own provider colour instead. + expect(getEntryAccentType('gmail')).toBeUndefined() + + withCategory('triggers') + expect(getEntryAccentType('schedule')).toBe('schedule') + expect(getEntryAccentType('some_vendor_trigger')).toBeUndefined() + }) + + it('accents subflows, which carry a role but no registry config', () => { + expect(getEntryAccentType('loop')).toBe('loop') + expect(getEntryAccentType('parallel')).toBe('parallel') + expect(getEntryAccentType('workflow')).toBe('workflow') + }) + + it('leaves the terminal-synthesized run rows on their own status fill', () => { + expect(getEntryAccentType('error')).toBeUndefined() + expect(getEntryAccentType('validation')).toBeUndefined() + expect(getEntryAccentType('cancelled')).toBeUndefined() + }) +}) From 9ec7abbdc2cb9e452c36715336b1e8ab757c0eb3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 00:02:12 -0700 Subject: [PATCH 06/11] fix(workflow): make notifications track panel and terminal resize live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast stack insets by --panel-width / --terminal-height, but a resize drag writes those to the resized subtree only (.panel-container / .terminal-container) rather than to :root, because a custom-property write on :root recalculates the whole document (~150x slower). The stack is portalled to , so it shares no ancestor with either and kept reading the stale :root value — it held its pre-drag position and jumped once the drag committed, while the canvas controls, which are laid out inside the shrinking canvas, tracked the drag in realtime. useDragResize now accepts several target subtrees and writes each one, so the scoped recalc is preserved and every consumer follows the drag frame by frame. The stack is found through a new data-toast-viewport attribute. Also drops the canvas controls from bottom-4 to bottom-2: the toast clears the terminal by 8px (it anchors from the viewport, and the terminal is inset by CONTENT_WINDOW_GAP), where the controls measure from the canvas floor and so sat at twice the gap. --- .../panel/hooks/use-panel-resize.ts | 17 +++++++++---- .../terminal/hooks/use-terminal-resize.ts | 17 +++++++++---- .../workflow-controls/workflow-controls.tsx | 9 ++++++- apps/sim/hooks/use-drag-resize.ts | 24 +++++++++++++++---- packages/emcn/src/components/toast/toast.tsx | 9 +++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index 5f20d08e113..0307e874178 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -13,9 +13,18 @@ function computePanelWidth(ev: PointerEvent): number { return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) } -/** The `.panel-container` element sizes itself from `--panel-width`. */ -function getPanelContainer(): HTMLElement | null { - return document.querySelector('.panel-container') +/** + * Every subtree that reads `--panel-width`: the `.panel-container` the drag + * resizes, and the toast stack, which insets its right edge by the same + * variable but is portalled to `` and so shares no ancestor with it. + * Writing both keeps the notifications tracking the drag frame by frame instead + * of jumping once it commits. + */ +function getPanelWidthConsumers(): (HTMLElement | null)[] { + return [ + document.querySelector('.panel-container'), + document.querySelector('[data-toast-viewport]'), + ] } /** @@ -33,7 +42,7 @@ export function usePanelResize() { return useDragResize({ cursor: 'ew-resize', cssVar: '--panel-width', - getTarget: getPanelContainer, + getTarget: getPanelWidthConsumers, compute: computePanelWidth, commit: setPanelWidth, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index 750e0c17358..58617a9dfe3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -13,9 +13,18 @@ function computeTerminalHeight(ev: PointerEvent): number { return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight) } -/** The `.terminal-container` element sizes itself from `--terminal-height`. */ -function getTerminalContainer(): HTMLElement | null { - return document.querySelector('.terminal-container') +/** + * Every subtree that reads `--terminal-height`: the `.terminal-container` the + * drag resizes, and the toast stack, which insets its bottom by the same + * variable but is portalled to `` and so shares no ancestor with it. + * Writing both keeps the notifications tracking the drag frame by frame instead + * of jumping once it commits. + */ +function getTerminalHeightConsumers(): (HTMLElement | null)[] { + return [ + document.querySelector('.terminal-container'), + document.querySelector('[data-toast-viewport]'), + ] } /** @@ -49,7 +58,7 @@ export function useTerminalResize() { return useDragResize({ cursor: 'ns-resize', cssVar: '--terminal-height', - getTarget: getTerminalContainer, + getTarget: getTerminalHeightConsumers, compute: computeTerminalHeight, commit: setTerminalHeight, onApply: syncExpandedThreshold, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index ccd03e4b7af..11f3c217f95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -92,7 +92,14 @@ export const WorkflowControls = memo(function WorkflowControls() { return ( <>
{/* Canvas Mode Selector */} diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts index 622196ea1e2..e494a00dad8 100644 --- a/apps/sim/hooks/use-drag-resize.ts +++ b/apps/sim/hooks/use-drag-resize.ts @@ -15,8 +15,16 @@ interface UseDragResizeOptions { * every custom-property write recalculates the whole tree (~150x slower). * Captured once on drag start; a `null` return falls back to * `document.documentElement`. + * + * Return an ARRAY when consumers live in sibling subtrees with no useful + * common ancestor — the toast stack is portalled to ``, so it shares + * one only with `:root`. Writing each subtree separately keeps the scoped + * recalc and, more importantly, keeps those consumers tracking the drag live; + * a consumer left off this list reads the stale `:root` value and only + * catches up when the drag commits. Elements that are absent (`null`) or + * repeated are ignored. */ - getTarget: () => HTMLElement | null + getTarget: () => HTMLElement | null | (HTMLElement | null)[] /** * Maps a pointer position to the clamped target dimension, or `null` to * ignore the move. Runs at most once per animation frame (before the write, @@ -90,7 +98,13 @@ export function useDragResize(options: UseDragResizeOptions) { const handle = e.currentTarget const pointerId = e.pointerId const { cssVar } = optionsRef.current - const target = optionsRef.current.getTarget() ?? document.documentElement + const resolved = optionsRef.current.getTarget() + const targets = [ + ...new Set((Array.isArray(resolved) ? resolved : [resolved]).filter((el) => el !== null)), + ] + if (targets.length === 0) targets.push(document.documentElement) + /** Liveness is judged on the primary target — the one the drag resizes. */ + const target = targets[0] document.body.style.cursor = optionsRef.current.cursor document.body.style.userSelect = 'none' handle.setPointerCapture?.(pointerId) @@ -100,7 +114,7 @@ export function useDragResize(options: UseDragResizeOptions) { let lastApplied: number | null = null const applyValue = (value: number) => { - target.style.setProperty(cssVar, `${value}px`) + for (const el of targets) el.style.setProperty(cssVar, `${value}px`) lastApplied = value optionsRef.current.onApply?.(value) } @@ -144,7 +158,9 @@ export function useDragResize(options: UseDragResizeOptions) { } if (lastApplied !== null) { optionsRef.current.commit(lastApplied) - if (target !== document.documentElement) target.style.removeProperty(cssVar) + for (const el of targets) { + if (el !== document.documentElement) el.style.removeProperty(cssVar) + } } optionsRef.current.onEnd?.() } diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 4798688cfc0..c5441b7681a 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -590,6 +590,15 @@ export function ToastProvider({ children }: { children?: ReactNode }) { aria-live='polite' aria-label='Notifications' data-native-surface-overlay='' + /* + * The stack is portalled to ``, so it shares no ancestor + * with the panel or terminal it insets by. A resize drag writes + * `--panel-width` / `--terminal-height` to each consuming + * subtree rather than to `:root`; this attribute is how it + * finds this one, and without it the stack would hold the + * pre-drag position until the drag commits. + */ + data-toast-viewport='' className='fixed z-[var(--z-toast)] m-0 list-none p-0' exit={{ opacity: 0, From 0835452ba6d688de75878cb63112a0286f351571 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 00:04:19 -0700 Subject: [PATCH 07/11] improvement(workflow): inset the canvas controls 8px off both edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast stack clears the terminal and the panel by 8px — it anchors from the viewport at --terminal-height/--panel-width + 16px, and both are themselves inset by CONTENT_WINDOW_GAP (8px). The controls measure from the canvas floor and wall instead, so their 16px read as twice the gap on both axes. --- .../workflow-controls/workflow-controls.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index 11f3c217f95..21ba2a9da2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -93,13 +93,14 @@ export const WorkflowControls = memo(function WorkflowControls() { <>
{/* Canvas Mode Selector */} From 9ffd45c9e1e90f8977ec0e5c83bf7a76061ebc59 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 00:06:19 -0700 Subject: [PATCH 08/11] improvement(workflow): lift the canvas controls and toasts to a 12px clearance 8px sat them too close to the terminal. 12 is on the same 4px grid as the surrounding spacing, where 10 would have been the only off-grid value in the area. Both surfaces clear the terminal and the panel by the same amount, so they read as one row; the toast's literals move into named insets rather than staying bare numbers in a style object. --- .../workflow-controls/workflow-controls.tsx | 14 ++++++------- packages/emcn/src/components/toast/toast.tsx | 21 +++++++++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index 21ba2a9da2b..c934e7c22ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -93,14 +93,14 @@ export const WorkflowControls = memo(function WorkflowControls() { <>
{/* Canvas Mode Selector */} diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index c5441b7681a..9675fc0e7f0 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -33,6 +33,19 @@ const AUTO_DISMISS_MS = 5000 /** Card width; tracks the workflow-panel inset on narrow viewports. */ const TOAST_WIDTH = 'min(100vw - 2rem, 280px)' +/** Gap from the viewport edge on an ordinary page. */ +const VIEWPORT_INSET_PX = 16 +/** + * Gap the stack keeps from the workflow panel and terminal it sits against. + * + * Larger than the 12px the edges actually end up apart, because the panel and + * terminal are themselves inset from the viewport by the workspace's own 8px + * content gap and `--panel-width` / `--terminal-height` measure only the + * element. The canvas controls clear the same two edges by the same 12px, so + * the two floating surfaces read as one row. + */ +const WORKFLOW_INSET_PX = 20 + /** Most toasts kept alive at once; older arrivals are evicted. */ const STACK_LIMIT = 3 /** Per-depth lift and shrink that make collapsed cards peek above the front one. */ @@ -605,8 +618,12 @@ export function ToastProvider({ children }: { children?: ReactNode }) { transition: reduceMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeIn' }, }} style={{ - right: isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px', - bottom: isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px', + right: isWorkflowPage + ? `calc(var(--panel-width) + ${WORKFLOW_INSET_PX}px)` + : `${VIEWPORT_INSET_PX}px`, + bottom: isWorkflowPage + ? `calc(var(--terminal-height) + ${WORKFLOW_INSET_PX}px)` + : `${VIEWPORT_INSET_PX}px`, width: TOAST_WIDTH, height: containerHeight, }} From 252045bb45ad6e2458f185866d0876b19a22c87a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 00:09:27 -0700 Subject: [PATCH 09/11] refactor(hooks): split the drag's resize target from its other var consumers getTarget briefly accepted a list, which made the first entry both the resized element and the drag's liveness reference. A toast auto-dismisses after 5s, so had one ever led that list, its mid-drag unmount would have read as the drag target detaching and skipped the final recompute on release. The co-consumers now come through getExtraTargets, which is written but never consulted for liveness, and can come and go freely. --- .../panel/hooks/use-panel-resize.ts | 23 +++++----- .../terminal/hooks/use-terminal-resize.ts | 25 +++++----- apps/sim/hooks/use-drag-resize.ts | 46 ++++++++++--------- 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index 0307e874178..fbecf8be6f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -13,18 +13,18 @@ function computePanelWidth(ev: PointerEvent): number { return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) } +/** The `.panel-container` element sizes itself from `--panel-width`. */ +function getPanelContainer(): HTMLElement | null { + return document.querySelector('.panel-container') +} + /** - * Every subtree that reads `--panel-width`: the `.panel-container` the drag - * resizes, and the toast stack, which insets its right edge by the same - * variable but is portalled to `` and so shares no ancestor with it. - * Writing both keeps the notifications tracking the drag frame by frame instead - * of jumping once it commits. + * The toast stack also insets its right edge by `--panel-width`, but is + * portalled to `` and so shares no ancestor with the panel. See + * `use-terminal-resize.ts` for why this is written alongside the primary. */ -function getPanelWidthConsumers(): (HTMLElement | null)[] { - return [ - document.querySelector('.panel-container'), - document.querySelector('[data-toast-viewport]'), - ] +function getToastViewport(): (HTMLElement | null)[] { + return [document.querySelector('[data-toast-viewport]')] } /** @@ -42,7 +42,8 @@ export function usePanelResize() { return useDragResize({ cursor: 'ew-resize', cssVar: '--panel-width', - getTarget: getPanelWidthConsumers, + getTarget: getPanelContainer, + getExtraTargets: getToastViewport, compute: computePanelWidth, commit: setPanelWidth, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index 58617a9dfe3..85da4747696 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -13,18 +13,20 @@ function computeTerminalHeight(ev: PointerEvent): number { return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight) } +/** The `.terminal-container` element sizes itself from `--terminal-height`. */ +function getTerminalContainer(): HTMLElement | null { + return document.querySelector('.terminal-container') +} + /** - * Every subtree that reads `--terminal-height`: the `.terminal-container` the - * drag resizes, and the toast stack, which insets its bottom by the same - * variable but is portalled to `` and so shares no ancestor with it. - * Writing both keeps the notifications tracking the drag frame by frame instead - * of jumping once it commits. + * The toast stack also insets its bottom by `--terminal-height`, but is + * portalled to `` and so shares no ancestor with the terminal. Writing it + * alongside keeps the notifications tracking the drag frame by frame instead of + * holding their pre-drag position until it commits. Usually absent — the stack + * only mounts while a toast is showing — in which case nothing extra is written. */ -function getTerminalHeightConsumers(): (HTMLElement | null)[] { - return [ - document.querySelector('.terminal-container'), - document.querySelector('[data-toast-viewport]'), - ] +function getToastViewport(): (HTMLElement | null)[] { + return [document.querySelector('[data-toast-viewport]')] } /** @@ -58,7 +60,8 @@ export function useTerminalResize() { return useDragResize({ cursor: 'ns-resize', cssVar: '--terminal-height', - getTarget: getTerminalHeightConsumers, + getTarget: getTerminalContainer, + getExtraTargets: getToastViewport, compute: computeTerminalHeight, commit: setTerminalHeight, onApply: syncExpandedThreshold, diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts index e494a00dad8..89928c14266 100644 --- a/apps/sim/hooks/use-drag-resize.ts +++ b/apps/sim/hooks/use-drag-resize.ts @@ -9,22 +9,30 @@ interface UseDragResizeOptions { */ cssVar: string /** - * Returns the element that consumes {@link cssVar} (or an ancestor of every - * consumer). During the drag the variable is written here — a style recalc - * scoped to that subtree — instead of on `:root`, where on a large document - * every custom-property write recalculates the whole tree (~150x slower). - * Captured once on drag start; a `null` return falls back to - * `document.documentElement`. + * Returns the element the drag resizes, which is also the subtree + * {@link cssVar} is written to during it — a style recalc scoped to that + * subtree, instead of `:root`, where on a large document every + * custom-property write recalculates the whole tree (~150x slower). Captured + * once on drag start; a `null` return falls back to + * `document.documentElement`. This element is also the drag's liveness + * reference: once it detaches, the release stops recomputing from layout. + */ + getTarget: () => HTMLElement | null + /** + * Other subtrees that read {@link cssVar} but are not what the drag resizes — + * the toast stack insets by `--panel-width`/`--terminal-height` yet is + * portalled to ``, so it shares no ancestor with either. Each is + * written alongside the primary, which keeps the recalc scoped AND keeps + * these consumers tracking the drag; one left off here reads the stale + * `:root` value and only catches up when the drag commits. * - * Return an ARRAY when consumers live in sibling subtrees with no useful - * common ancestor — the toast stack is portalled to ``, so it shares - * one only with `:root`. Writing each subtree separately keeps the scoped - * recalc and, more importantly, keeps those consumers tracking the drag live; - * a consumer left off this list reads the stale `:root` value and only - * catches up when the drag commits. Elements that are absent (`null`) or - * repeated are ignored. + * Deliberately separate from {@link getTarget} rather than one list: these + * come and go independently of the drag (a toast auto-dismisses mid-drag), + * so they must never become the liveness reference. Absent (`null`) or + * duplicate elements are ignored, and writing to one that detaches mid-drag + * is harmless. */ - getTarget: () => HTMLElement | null | (HTMLElement | null)[] + getExtraTargets?: () => (HTMLElement | null)[] /** * Maps a pointer position to the clamped target dimension, or `null` to * ignore the move. Runs at most once per animation frame (before the write, @@ -98,13 +106,9 @@ export function useDragResize(options: UseDragResizeOptions) { const handle = e.currentTarget const pointerId = e.pointerId const { cssVar } = optionsRef.current - const resolved = optionsRef.current.getTarget() - const targets = [ - ...new Set((Array.isArray(resolved) ? resolved : [resolved]).filter((el) => el !== null)), - ] - if (targets.length === 0) targets.push(document.documentElement) - /** Liveness is judged on the primary target — the one the drag resizes. */ - const target = targets[0] + const target = optionsRef.current.getTarget() ?? document.documentElement + const extras = optionsRef.current.getExtraTargets?.() ?? [] + const targets = [...new Set([target, ...extras.filter((el) => el !== null)])] document.body.style.cursor = optionsRef.current.cursor document.body.style.userSelect = 'none' handle.setPointerCapture?.(pointerId) From 77b0db93b1ce94aa6664a78c9ab35c0080558c8a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 00:14:17 -0700 Subject: [PATCH 10/11] fix(emcn): stop the combobox cursor diverging from what Enter commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option rows painted --surface-active from CSS :hover as well as from isHighlighted. CSS :hover tracks the pointer continuously while highlightedIndex only advances on mouseenter, so once the list scrolled under a stationary pointer the row that looked selected was not the one Enter would commit — Enter reads filteredOptions[highlightedIndex]. isHighlighted is now the single source of truth for the cursor, so paint and commit cannot disagree. The row under a stationary pointer may lag a scroll until the mouse moves, but it lags in agreement with what Enter will do, which is the invariant worth keeping. Disabled options also stop painting on hover, matching the mouseenter guard that already refused to highlight them. The 'All' row keeps its own hover: it clears the highlight rather than taking it, so it has no isHighlighted paint to fall back on. --- .../emcn/src/components/combobox/combobox.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 2b52d793017..391e1028a1f 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -151,14 +151,6 @@ export interface ComboboxProps emptyMessage?: string } -/** - * Option cursor, not a hover affordance: it matches the keyboard highlight, so it - * keeps `--surface-active` rather than the dimmer `chipHoverSurfaceClass` rows - * use. Kept alongside `isHighlighted` because `onMouseEnter` does not re-fire - * when the list scrolls under a stationary pointer. - */ -const OPTION_CURSOR_CLASS = 'hover-hover:bg-[var(--surface-active)]' - /** * Minimal combobox component matching the input and textarea styling. * Provides a dropdown selection interface with keyboard navigation support. @@ -850,7 +842,16 @@ const Combobox = memo( className={cn( 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans', size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', - OPTION_CURSOR_CLASS, + /* + No CSS `:hover` here — `isHighlighted` is the + single source of truth for the cursor, because + it is also what Enter commits. A `:hover` class + tracks the pointer continuously while + `highlightedIndex` only moves on `mouseenter`, + so after the list scrolls under a stationary + pointer the two disagree and the row that looks + selected is not the one Enter would choose. + */ (isHighlighted || isSelected) && chipActiveSurfaceClass, option.disabled && 'cursor-not-allowed opacity-50' )} @@ -926,7 +927,7 @@ const Combobox = memo( className={cn( 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans', size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm', - OPTION_CURSOR_CLASS, + // See above: `isHighlighted` alone, so paint matches what Enter commits. (isHighlighted || isSelected) && chipActiveSurfaceClass, option.disabled && 'cursor-not-allowed opacity-50' )} From a13712cfa2a04a2c7146bc0189777f83d5a87e6b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 00:19:47 -0700 Subject: [PATCH 11/11] fix(toast): derive the workflow inset from the shell's actual padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORKFLOW_INSET_PX baked in the 8px the workspace shell normally insets the panel and terminal by, so the stack's 20px resolved to a 12px clearance — matching the canvas controls. But the shell drops to p-0 on the desktop title-bar shell with a collapsed sidebar, and there the stack would have sat 20px out while the controls, laid out inside the shell, stayed at 12. The stack now adds --workspace-content-gap (published on :root, zeroed by the same condition that zeroes the padding) to a flat 12, so the two surfaces hold the same clearance in both configurations. Before this PR they matched in the p-0 case at 16px each, so this closes a divergence the PR would otherwise have introduced. --- apps/sim/app/_styles/globals.css | 20 ++++++++++++++++++ packages/emcn/src/components/toast/toast.tsx | 22 ++++++++++++-------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 494dfcdd9ad..b467e4bfc93 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -22,6 +22,18 @@ --panel-width: 320px; /* PANEL_WIDTH.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */ + /** + * The padding `.workspace-content-shell` insets the panel and terminal from + * the viewport by (CONTENT_WINDOW_GAP). + * + * Published here because surfaces portalled to `` — the toast stack — + * position against those elements from the viewport, so they must add back + * whatever separates the element from the viewport edge. Reading it rather + * than hardcoding 8px is what keeps the toast and the canvas controls on the + * same clearance when the shell drops its padding; the controls are laid out + * inside the shell and so need no correction. + */ + --workspace-content-gap: 8px; --output-panel-width: 560px; /* OUTPUT_PANEL_WIDTH.DEFAULT */ /** * Neutral border and divider thickness. Standard-density displays cannot draw @@ -162,6 +174,14 @@ html[data-sim-desktop-title-bar="inset"] --workspace-content-title-bar-inset: var(--desktop-title-bar-height); } +/* The one case the shell drops its padding entirely (see `workspace-chrome.tsx`: + `isCollapsed && '[[data-sim-desktop-title-bar=inset]_&]:p-0'`). Declared on the + root so the portalled toast stack — which cannot inherit from the shell — sees + it too, and keeps the same clearance the in-shell canvas controls keep. */ +html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sidebar-collapsed]) { + --workspace-content-gap: 0px; +} + .workspace-root code, .workspace-root kbd, .workspace-root samp, diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 9675fc0e7f0..23bb9226c0a 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -36,15 +36,19 @@ const TOAST_WIDTH = 'min(100vw - 2rem, 280px)' /** Gap from the viewport edge on an ordinary page. */ const VIEWPORT_INSET_PX = 16 /** - * Gap the stack keeps from the workflow panel and terminal it sits against. + * Gap the stack keeps from the workflow panel and terminal it sits against — + * the same one the canvas controls keep, so the two floating surfaces read as + * one row. * - * Larger than the 12px the edges actually end up apart, because the panel and - * terminal are themselves inset from the viewport by the workspace's own 8px - * content gap and `--panel-width` / `--terminal-height` measure only the - * element. The canvas controls clear the same two edges by the same 12px, so - * the two floating surfaces read as one row. + * `--panel-width` / `--terminal-height` measure the element, not its distance + * from the viewport, and the stack is portalled to `` so it anchors from + * the viewport. `--workspace-content-gap` adds back whatever padding the + * workspace shell insets those elements by — normally 8px, but 0 on the desktop + * shell with a collapsed sidebar. Hardcoding the sum would silently hold the + * stack 8px further out in that configuration while the controls, which are laid + * out inside the shell, stayed put. */ -const WORKFLOW_INSET_PX = 20 +const WORKFLOW_INSET_PX = 12 /** Most toasts kept alive at once; older arrivals are evicted. */ const STACK_LIMIT = 3 @@ -619,10 +623,10 @@ export function ToastProvider({ children }: { children?: ReactNode }) { }} style={{ right: isWorkflowPage - ? `calc(var(--panel-width) + ${WORKFLOW_INSET_PX}px)` + ? `calc(var(--panel-width) + var(--workspace-content-gap, 0px) + ${WORKFLOW_INSET_PX}px)` : `${VIEWPORT_INSET_PX}px`, bottom: isWorkflowPage - ? `calc(var(--terminal-height) + ${WORKFLOW_INSET_PX}px)` + ? `calc(var(--terminal-height) + var(--workspace-content-gap, 0px) + ${WORKFLOW_INSET_PX}px)` : `${VIEWPORT_INSET_PX}px`, width: TOAST_WIDTH, height: containerHeight,