improvement(ui): align terminal with the workflow design system, fix row hover states (#6534)

* 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

* 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

* improvement(workflow): align canvas controls with the canvas surface and icon scale

- floating controls sit on --surface-2, the surface the block cards use, rather
  than --surface-1 (the sidebar/panel surface)
- undo/redo/fit glyphs drop 16px -> the platform's 14px default, and the mode
  dropdown's own 12px icons come up to match; the control had three icon sizes
- inactive buttons hover to --surface-hover instead of --surface-5, which was
  the active mode button's resting fill, so hovering one looked selected
- inner radius goes concentric with the 4px padding (rounded-sm inside
  rounded-lg)

* 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.

* test(terminal): lock the log-row accent rule against the block toolbar

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.

* fix(workflow): make notifications track panel and terminal resize live

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 <body>, 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.

* improvement(workflow): inset the canvas controls 8px off both edges

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.

* 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.

* 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.

* fix(emcn): stop the combobox cursor diverging from what Enter commits

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.

* fix(toast): derive the workflow inset from the shell's actual padding

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.
This commit is contained in:
Waleed
2026-08-11 00:28:08 -07:00
committed by GitHub
parent 155192330c
commit 2f43148d60
31 changed files with 492 additions and 338 deletions
+1 -1
View File
@@ -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.
+5 -11
View File
@@ -518,19 +518,13 @@ 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 — 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-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 */
@@ -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,7 +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-active`, then `--surface-6` when an active row is hovered.
* 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
@@ -52,20 +53,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)]'
const ITEM_ACTIVE_MOBILE = '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 = 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)]'
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 to inactive rows — an unconditional hover in
* `ITEM_BASE` would fade the current page under the pointer below `lg`.
*/
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 = '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 = 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()
@@ -73,19 +79,7 @@ export function SidebarItem({ item }: { item: Item }) {
const active = isActive(item.url, pathname, false)
return (
<Link
href={item.url}
prefetch={prefetch}
data-active={active}
className={cn(
ITEM_BASE,
active && ITEM_ACTIVE_MOBILE,
ITEM_DESKTOP,
ITEM_TEXT,
!active && ITEM_HOVER,
active && ITEM_ACTIVE
)}
>
<Link href={item.url} prefetch={prefetch} data-active={active} className={itemClass(active)}>
{item.name}
</Link>
)
@@ -119,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}
</Link>
@@ -144,12 +131,10 @@ 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)]',
active && ITEM_ACTIVE_MOBILE,
'text-[var(--text-body)]',
'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}
@@ -158,8 +143,8 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
<button
onClick={toggleOpen}
className={cn(
'rounded-md p-1 hover:bg-[var(--surface-active)]',
'lg:cursor-pointer lg:rounded-md lg:p-1 lg:transition-colors lg:hover:bg-[var(--surface-active)]'
'rounded-md p-1 transition-colors lg:cursor-pointer',
chipHoverSurfaceClass
)}
aria-label={open ? 'Collapse' : 'Expand'}
>
@@ -172,7 +157,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
onClick={toggleOpen}
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)]',
'lg:flex lg:w-full lg:cursor-pointer lg:items-center lg:justify-between lg:rounded-lg lg:px-2 lg:text-left lg:text-sm lg:leading-tight',
FOLDER_TEXT,
FOLDER_HOVER
@@ -1,5 +1,12 @@
'use client'
import { ChevronDown, cn, Home, Library } from '@sim/emcn'
import {
ChevronDown,
chipActiveSurfaceClass,
chipHoverSurfaceClass,
cn,
Home,
Library,
} from '@sim/emcn'
import { Database, File, HelpCircle, Search, Settings, Table, Workflow } from '@sim/emcn/icons'
import type { PreviewWorkflow } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
@@ -51,8 +58,8 @@ function NavItem({
type='button'
onClick={onClick}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isActive && 'bg-[var(--c-active)]'
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors',
isActive ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
@@ -77,10 +84,7 @@ export function LandingPreviewSidebar({
const isHomeActive = activeView === 'home'
return (
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col bg-[var(--surface-1)] pt-3'
style={{ '--c-active': 'var(--surface-active)' } as React.CSSProperties}
>
<div className='flex h-full w-[248px] flex-shrink-0 flex-col bg-[var(--surface-1)] pt-3'>
{/* Workspace Header */}
<div className='flex-shrink-0 px-2.5'>
<div className='pointer-events-none flex h-[32px] w-full items-center gap-2 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] pr-2 pl-[5px]'>
@@ -107,8 +111,8 @@ export function LandingPreviewSidebar({
type='button'
onClick={onSelectHome}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isHomeActive && 'bg-[var(--c-active)]'
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors',
isHomeActive ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<Home className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
@@ -151,8 +155,8 @@ export function LandingPreviewSidebar({
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className={cn(
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--surface-active)]',
isActive && 'bg-[var(--surface-active)]'
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors',
isActive ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
+20
View File
@@ -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 `<body>` — 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,
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { cn, Tooltip } from '@sim/emcn'
import { chipActiveSurfaceClass, chipHoverSurfaceClass, cn, Tooltip } from '@sim/emcn'
import { ArrowUp, ChevronDown, ChevronRight, Paperclip, Pencil, Trash, X } from '@sim/emcn/icons'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
@@ -54,7 +54,10 @@ export function QueuedMessages({
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center gap-1.5 px-3.5 py-2 transition-colors hover-hover:bg-[var(--surface-active)]'
className={cn(
'flex w-full items-center gap-1.5 px-3.5 py-2 transition-colors',
chipHoverSurfaceClass
)}
>
{isExpanded ? (
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
@@ -75,8 +78,8 @@ export function QueuedMessages({
<div
key={msg.id}
className={cn(
'flex items-center gap-2 py-1.5 pr-2 pl-3.5 transition-colors hover-hover:bg-[var(--surface-active)]',
isEditing && 'bg-[var(--surface-active)]'
'flex items-center gap-2 py-1.5 pr-2 pl-3.5 transition-colors',
isEditing ? chipActiveSurfaceClass : chipHoverSurfaceClass
)}
>
<div className='flex size-[16px] shrink-0 items-center justify-center'>
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Badge,
Button,
Chip,
ChipCombobox,
ChipInput,
type ComboboxOption,
@@ -506,9 +506,9 @@ export function McpDeploy({
<p className='text-[var(--text-muted)] text-small'>
Create an MCP Server to expose your workflows as tools.
</p>
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
<Chip variant='primary' onClick={() => setShowCreateModal(true)}>
Create MCP Server
</Button>
</Chip>
</div>
<CreateWorkflowMcpServerModal
open={showCreateModal}
@@ -3,8 +3,10 @@
import { useEffect, useRef, useState } from 'react'
import {
Badge,
Button,
Chip,
ChipConfirmModal,
chipContentIconClass,
cn,
Loader,
Modal,
ModalBody,
@@ -624,16 +626,14 @@ export function DeployModal({
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
Edit API Info
</Button>
<Button
variant='tertiary'
<Chip onClick={() => setIsApiInfoModalOpen(true)}>Edit API Info</Chip>
<Chip
variant='primary'
onClick={() => setIsCreateKeyModalOpen(true)}
disabled={createButtonDisabled}
>
Generate API Key
</Button>
</Chip>
</div>
</ModalFooter>
)}
@@ -642,18 +642,13 @@ export function DeployModal({
<div />
<div className='flex items-center gap-2'>
{chatExists && (
<Button
type='button'
variant='default'
onClick={handleChatDelete}
disabled={chatSubmitting}
>
<Chip type='button' onClick={handleChatDelete} disabled={chatSubmitting}>
Delete
</Button>
</Chip>
)}
<Button
<Chip
type='button'
variant='tertiary'
variant='primary'
onClick={handleChatFormSubmit}
disabled={chatSubmitting || !isChatFormValid}
>
@@ -668,7 +663,7 @@ export function DeployModal({
: chatExists
? 'Update'
: 'Launch Chat'}
</Button>
</Chip>
</div>
</ModalFooter>
)}
@@ -676,9 +671,8 @@ export function DeployModal({
<ModalFooter className='items-center justify-between'>
<div />
<div className='flex items-center gap-2'>
<Button
<Chip
type='button'
variant='default'
onClick={() =>
navigateToSettings({
section: 'workflow-mcp-servers',
@@ -687,18 +681,18 @@ export function DeployModal({
}
>
Manage
</Button>
</Chip>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span>
<Button
<Chip
type='button'
variant='tertiary'
variant='primary'
onClick={handleMcpToolFormSubmit}
disabled={mcpToolSubmitting || !mcpToolCanSave}
>
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
</Button>
</Chip>
</span>
</Tooltip.Trigger>
{mcpToolSaveDisabledReason && (
@@ -882,16 +876,23 @@ function GeneralFooter({
</div>
)
const deployActionLoading = isSubmitting || isDeploymentSettling
const deployLoader = deployActionLoading ? (
<Loader className={cn(chipContentIconClass, 'text-current')} animate />
) : null
if (!isDeployed) {
return (
<ModalFooter className='items-center justify-between'>
{status}
<div className='flex items-center gap-2'>
<Button variant='tertiary' onClick={onDeploy} disabled={isDeployBlocked}>
{deployActionLoading && <Loader className='mr-1.5 size-3.5' animate />}
<Chip
variant='primary'
onClick={onDeploy}
disabled={isDeployBlocked}
leftAdornment={deployLoader}
>
Deploy
</Button>
</Chip>
</div>
</ModalFooter>
)
@@ -901,14 +902,18 @@ function GeneralFooter({
<ModalFooter className='items-center justify-between'>
{status}
<div className='flex items-center gap-2'>
<Button variant='default' onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
<Chip onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</Chip>
{(needsRedeployment || isDeploymentSettling) && (
<Button variant='tertiary' onClick={onRedeploy} disabled={isDeployBlocked}>
{deployActionLoading && <Loader className='mr-1.5 size-3.5' animate />}
<Chip
variant='primary'
onClick={onRedeploy}
disabled={isDeployBlocked}
leftAdornment={deployLoader}
>
Update
</Button>
</Chip>
)}
</div>
</ModalFooter>
@@ -18,6 +18,15 @@ function getPanelContainer(): HTMLElement | null {
return document.querySelector<HTMLElement>('.panel-container')
}
/**
* The toast stack also insets its right edge by `--panel-width`, but is
* portalled to `<body>` and so shares no ancestor with the panel. See
* `use-terminal-resize.ts` for why this is written alongside the primary.
*/
function getToastViewport(): (HTMLElement | null)[] {
return [document.querySelector<HTMLElement>('[data-toast-viewport]')]
}
/**
* Handles panel drag-resize with zero React renders during the drag. The
* `--panel-width` variable is written to `.panel-container` (a scoped style
@@ -34,6 +43,7 @@ export function usePanelResize() {
cursor: 'ew-resize',
cssVar: '--panel-width',
getTarget: getPanelContainer,
getExtraTargets: getToastViewport,
compute: computePanelWidth,
commit: setPanelWidth,
})
@@ -0,0 +1,35 @@
'use client'
import { memo } from 'react'
import { chipIconSlotClass, cn } from '@sim/emcn'
import { WorkflowTypeIcon } from '@sim/workflow-renderer'
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. @see getEntryAccentType */
export const EntryBlockTile = memo(function EntryBlockTile({ blockType }: EntryBlockTileProps) {
const BlockIcon = getBlockIcon(blockType)
const bgColor = getBlockColor(blockType)
const accentType = getEntryAccentType(blockType)
if (BlockIcon && accentType) {
return <WorkflowTypeIcon type={accentType} Icon={BlockIcon} />
}
return (
<div
className={cn(chipIconSlotClass, 'overflow-hidden rounded-md [&_img]:size-full')}
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className={cn('size-[10px]', getTileIconColorClass(bgColor))} />}
</div>
)
})
@@ -0,0 +1 @@
export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile'
@@ -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({
<PopoverSection className='!mt-0'>Blocks</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{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 && <BlockIcon className='size-3' />}
<EntryBlockTile blockType={block.blockType} />
<span className='flex-1'>{block.blockName}</span>
</PopoverItem>
)
@@ -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'
@@ -11,7 +11,14 @@ import {
useRef,
useState,
} from 'react'
import { Badge, ChevronDown, cn } from '@sim/emcn'
import {
Badge,
ChevronDown,
chipGeometryClass,
chipHoverSurfaceClass,
cn,
disclosureChevronClass,
} from '@sim/emcn'
import { useVirtualizer } from '@tanstack/react-virtual'
import { isUserFileDisplayMetadata } from '@/lib/core/utils/user-file'
import {
@@ -19,9 +26,12 @@ 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'
interface NodeEntry {
key: string
@@ -51,28 +61,33 @@ const CONFIG = {
VIRTUALIZATION_THRESHOLD: 200,
} as const
const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
string: 'green',
number: 'blue',
boolean: 'orange',
array: 'purple',
null: 'gray',
undefined: 'gray',
object: 'gray',
} as const
/**
* One neutral tag for every value type. Per-type hues re-encoded a fact the tag
* text and the value already state, and left red — the only signal that matters
* — competing with decoration. Red stays; nothing else is coloured.
*/
const VALUE_TYPE_BADGE_VARIANT = 'gray-secondary' as const
/**
* Styling constants matching the original non-virtualized implementation.
* Styling constants for the output tree — the same chip as a log row.
*
* `row` overrides the pill's fixed `h-[30px]` with `h-auto min-h-[30px]`: a
* wrapped value grows past one line.
*/
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: cn(
'group flex cursor-pointer transition-colors',
chipGeometryClass,
'-mx-2 h-auto min-h-[30px]',
chipHoverSurfaceClass
),
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 +414,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 +669,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 (
<div style={{ paddingLeft }} data-row-index={index}>
@@ -346,7 +346,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-3.5 w-3.5' />
<X className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -362,7 +362,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<Search className='h-3.5 w-3.5' />
<Search className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -380,7 +380,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-3.5 w-3.5' />
<Palette className='size-[14px]' />
</Button>
</Link>
</Tooltip.Trigger>
@@ -399,9 +399,9 @@ export const OutputPanel = React.memo(function OutputPanel({
className='!p-1.5 -m-1.5'
>
{showCopySuccess ? (
<Check className='h-3.5 w-3.5' />
<Check className='size-[14px]' />
) : (
<Clipboard className='h-3.5 w-3.5' />
<Clipboard className='size-[14px]' />
)}
</Button>
</Tooltip.Trigger>
@@ -419,7 +419,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Export console CSV'
className='!p-1.5 -m-1.5'
>
<Download className='h-3.5 w-3.5' />
<Download className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -434,7 +434,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash className='h-3.5 w-3.5' />
<Trash className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -451,7 +451,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Terminal options'
className='!p-1.5 -m-1.5'
>
<MoreHorizontal className='h-3.5 w-3.5' />
<MoreHorizontal className='size-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent
@@ -517,7 +517,7 @@ export const OutputPanel = React.memo(function OutputPanel({
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowUp className='h-3.5 w-3.5' />
<ArrowUp className='size-[14px]' />
</Button>
<Button
variant='ghost'
@@ -526,7 +526,7 @@ export const OutputPanel = React.memo(function OutputPanel({
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowDown className='h-3.5 w-3.5' />
<ArrowDown className='size-[14px]' />
</Button>
<Button
variant='ghost'
@@ -534,7 +534,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-3.5 w-3.5' />
<X className='size-[14px]' />
</Button>
</div>
)}
@@ -1 +1 @@
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
export { StatusDisplay, type StatusDisplayProps } from './status-display'
@@ -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 (
<Badge variant='green' className={BADGE_STYLE}>
Running
@@ -18,6 +18,17 @@ function getTerminalContainer(): HTMLElement | null {
return document.querySelector<HTMLElement>('.terminal-container')
}
/**
* The toast stack also insets its bottom by `--terminal-height`, but is
* portalled to `<body>` 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 getToastViewport(): (HTMLElement | null)[] {
return [document.querySelector<HTMLElement>('[data-toast-viewport]')]
}
/**
* Updates the store height mid-drag only when it crosses the expanded
* threshold, so `isExpanded` subscribers (header chevron, auto-open logic)
@@ -50,6 +61,7 @@ export function useTerminalResize() {
cursor: 'ns-resize',
cssVar: '--terminal-height',
getTarget: getTerminalContainer,
getExtraTargets: getToastViewport,
compute: computeTerminalHeight,
commit: setTerminalHeight,
onApply: syncExpandedThreshold,
@@ -5,6 +5,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
ChevronDown,
disclosureChevronClass,
handleKeyboardActivation,
Popover,
PopoverContent,
@@ -22,6 +23,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 +41,6 @@ import {
type ExecutionGroup,
flattenBlockEntriesOnly,
flattenVisibleExecutionRows,
getBlockColor,
getBlockIcon,
groupEntriesByExecution,
isEventFromEditableElement,
type NavigableBlockEntry,
@@ -48,7 +48,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 +69,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 +105,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 (
<div
data-entry-id={entry.id}
role='button'
tabIndex={0}
className={clsx(
ROW_STYLES.base,
'h-[30px]',
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
)}
className={isSelected ? ROW_STYLES.rowSelected : ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
onSelect(entry)
@@ -120,25 +123,13 @@ const BlockRow = memo(function BlockRow({
handleKeyboardActivation(event, () => onSelect(entry), { stopPropagation: true })
}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: bgColor }}
>
{BlockIcon && (
<BlockIcon className={clsx('size-[10px]', getTileIconColorClass(bgColor))} />
)}
</div>
<span
className={clsx(
'min-w-0 truncate text-sm',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
<div className={ROW_STYLES.content}>
<EntryBlockTile blockType={entry.blockType} />
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
{entry.blockName}
</span>
</div>
<span className={clsx('flex-shrink-0 text-sm', !isRunning && 'text-[var(--text-secondary)]')}>
<span className={clsx(ROW_STYLES.status, !isRunning && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={isRunning}
isCanceled={isCanceled}
@@ -187,37 +178,22 @@ const IterationNodeRow = memo(function IterationNodeRow({
<div
role='button'
tabIndex={0}
className={clsx(ROW_STYLES.base, 'h-[30px]', ROW_STYLES.hover)}
className={ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
onKeyDown={(event) => handleKeyboardActivation(event, onToggle, { stopPropagation: true })}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<span
className={clsx(
'min-w-0 truncate text-sm',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
<div className={ROW_STYLES.content}>
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
{iterationLabel}
</span>
{hasChildren && (
<ChevronDown
className={clsx(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
!isExpanded && '-rotate-90'
)}
/>
<ChevronDown className={clsx(disclosureChevronClass, !isExpanded && '-rotate-90')} />
)}
</div>
<span
className={clsx(
'flex-shrink-0 text-sm',
!hasRunningChild && 'text-[var(--text-secondary)]'
)}
>
<span className={clsx(ROW_STYLES.status, !hasRunningChild && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={hasRunningChild}
isCanceled={hasCanceledChild}
@@ -264,9 +240,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
renderChildren?: boolean
}) {
const { entry, children } = node
const BlockIcon = getBlockIcon(entry.blockType)
const hasError = Boolean(entry.error) || hasErrorInTree(children)
const bgColor = getBlockColor(entry.blockType)
const nodeId = entry.id
const isExpanded = expandedNodes.has(nodeId)
const hasChildren = children.length > 0
@@ -287,7 +261,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<div
role='button'
tabIndex={0}
className={clsx(ROW_STYLES.base, 'h-[30px]', ROW_STYLES.hover)}
className={ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
onToggleNode(nodeId)
@@ -296,38 +270,14 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
handleKeyboardActivation(event, () => onToggleNode(nodeId), { stopPropagation: true })
}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: bgColor }}
>
{BlockIcon && (
<BlockIcon className={clsx('size-[10px]', getTileIconColorClass(bgColor))} />
)}
</div>
<span
className={clsx(
'min-w-0 truncate text-sm',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
{displayName}
</span>
<div className={ROW_STYLES.content}>
<EntryBlockTile blockType={entry.blockType} />
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>{displayName}</span>
{hasChildren && (
<ChevronDown
className={clsx(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
!isExpanded && '-rotate-90'
)}
/>
<ChevronDown className={clsx(disclosureChevronClass, !isExpanded && '-rotate-90')} />
)}
</div>
<span
className={clsx(
'flex-shrink-0 text-sm',
!hasRunningDescendant && 'text-[var(--text-secondary)]'
)}
>
<span className={clsx(ROW_STYLES.status, !hasRunningDescendant && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
@@ -376,8 +326,6 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
renderChildren?: boolean
}) {
const { entry, children } = node
const BlockIcon = getBlockIcon(entry.blockType)
const bgColor = getBlockColor(entry.blockType)
const nodeId = entry.id
const isExpanded = expandedNodes.has(nodeId)
const hasChildren = children.length > 0
@@ -402,11 +350,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
<div
role='button'
tabIndex={0}
className={clsx(
ROW_STYLES.base,
'h-[30px]',
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
)}
className={isSelected ? ROW_STYLES.rowSelected : ROW_STYLES.row}
onClick={(e) => {
e.stopPropagation()
if (!isSelected) onSelectEntry(entry)
@@ -423,38 +367,16 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
)
}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
style={{ background: bgColor }}
>
{BlockIcon && (
<BlockIcon className={clsx('size-[10px]', getTileIconColorClass(bgColor))} />
)}
</div>
<span
className={clsx(
'min-w-0 truncate text-sm',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
<div className={ROW_STYLES.content}>
<EntryBlockTile blockType={entry.blockType} />
<span className={hasError ? ROW_STYLES.labelError : ROW_STYLES.label}>
{entry.blockName}
</span>
{hasChildren && (
<ChevronDown
className={clsx(
'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
!isExpanded && '-rotate-90'
)}
/>
<ChevronDown className={clsx(disclosureChevronClass, !isExpanded && '-rotate-90')} />
)}
</div>
<span
className={clsx(
'flex-shrink-0 text-sm',
!hasRunningDescendant && 'text-[var(--text-secondary)]'
)}
>
<span className={clsx(ROW_STYLES.status, !hasRunningDescendant && ROW_STYLES.statusIdle)}>
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
@@ -570,15 +492,15 @@ function TerminalLogListRow({
}: TerminalLogListRowProps) {
if (row.rowType === 'separator') {
return (
<div className='px-[6px]'>
<div className='mx-[4px] mt-[6px] border-[var(--border)] border-t' />
<div className={LOG_ROW_GUTTER_CLASS}>
<div className='mt-[6px] border-[var(--border)] border-t' />
</div>
)
}
return (
<div className='px-[6px]'>
<div className='ml-[4px]' style={{ paddingLeft: row.depth === 0 ? 0 : row.depth * 16 }}>
<div className={LOG_ROW_GUTTER_CLASS}>
<div style={{ paddingLeft: row.depth === 0 ? 0 : row.depth * 16 }}>
<EntryNodeRow
node={row.node!}
selectedEntryId={selectedEntryId}
@@ -1329,9 +1251,9 @@ export const Terminal = memo(function Terminal() {
className='!p-1.5 -m-1.5'
>
{sortDirection === 'desc' ? (
<ArrowDown className='size-3.5' />
<ArrowDown className='size-[14px]' />
) : (
<ArrowUp className='size-3.5' />
<ArrowUp className='size-[14px]' />
)}
</Button>
</Tooltip.Trigger>
@@ -1350,7 +1272,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-3.5 w-3.5' />
<Palette className='size-[14px]' />
</Button>
</Link>
</Tooltip.Trigger>
@@ -1370,7 +1292,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Export console CSV'
className='!p-1.5 -m-1.5'
>
<Download className='h-3.5 w-3.5' />
<Download className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -1385,7 +1307,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash className='h-3.5 w-3.5' />
<Trash className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -1405,7 +1327,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Terminal options'
className='!p-1.5 -m-1.5'
>
<MoreHorizontal className='h-3.5 w-3.5' />
<MoreHorizontal className='size-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent
@@ -1,3 +1,5 @@
import { chipContentGap, chipContentLabelClass, chipVariants, cn } from '@sim/emcn'
/**
* Terminal filter configuration state
*/
@@ -34,12 +36,20 @@ export interface BlockInfo {
}
/**
* Common row styling classes for terminal components
* Common row styling classes for terminal components.
*
* A log row IS a chip — it renders `chipVariants` rather than restating it, so
* geometry, hover and selected follow the pill automatically. `justify-between`
* is the only delta: a log row pushes its duration to the far edge.
*/
export const ROW_STYLES = {
base: 'group flex cursor-pointer items-center justify-between gap-2 rounded-lg px-2',
selected: 'bg-[var(--surface-active)]',
hover: 'hover:bg-[var(--surface-active)]',
row: cn(chipVariants({ fullWidth: true }), 'justify-between'),
rowSelected: cn(chipVariants({ fullWidth: true, active: true }), 'justify-between'),
content: `flex min-w-0 flex-1 items-center ${chipContentGap}`,
label: chipContentLabelClass,
labelError: cn(chipContentLabelClass, 'text-[var(--text-error)]'),
status: 'flex-shrink-0 text-sm',
statusIdle: 'text-[var(--text-muted)]',
nested: 'mt-0.5 ml-[3px] flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-[9px]',
iconButton: '!p-1.5 -m-1.5',
} as const
@@ -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()
})
})
@@ -1,5 +1,6 @@
import type React from 'react'
import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons'
import { hasWorkflowTypeRole } from '@sim/workflow-renderer'
import { getBlock } from '@/blocks'
import { isWorkflowBlockType } from '@/executor/constants'
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
@@ -93,6 +94,23 @@ export function getBlockColor(blockType: string): string {
return '#6b7280'
}
/**
* 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: a core block always takes the canvas role accent (an
* unmapped one lands on `neutral`, exactly as it does in the toolbar), and
* anything else — integrations, triggers, subflows — takes one only if it has a
* role. That second clause is what leaves the terminal's synthesized
* `error`/`validation`/`cancelled` rows on their own status fill: they carry no
* config and no role, so they fall through to the provider-colour branch.
*/
export function getEntryAccentType(blockType: string): string | undefined {
const isCoreBlock = getBlock(blockType)?.category === 'blocks'
return isCoreBlock || hasWorkflowTypeRole(blockType) ? blockType : undefined
}
/**
* Determines if a keyboard event originated from a text-editable element
*/
@@ -645,20 +663,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
*/
@@ -5,6 +5,9 @@ import {
Button,
ChevronDown,
Cursor,
chipHoverSurfaceClass,
cn,
disclosureChevronClass,
Hand,
Popover,
PopoverAnchor,
@@ -89,7 +92,15 @@ export const WorkflowControls = memo(function WorkflowControls() {
return (
<>
<div
className='absolute bottom-4 left-[16px] z-10 flex h-[36px] items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--surface-1)] p-1'
/*
* 12px off both edges, the same clearance the toast stack keeps from the
* terminal and the panel, so the two floating surfaces read as one row.
* The toast reaches it as `--terminal-height + 20px` because it anchors
* from the viewport and the terminal is itself inset by
* CONTENT_WINDOW_GAP; these controls measure from the canvas floor and
* wall, so they take the 12 directly.
*/
className='absolute bottom-3 left-3 z-10 flex h-[36px] items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-1'
onContextMenu={handleContextMenu}
>
{/* Canvas Mode Selector */}
@@ -98,7 +109,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-1'>
<Tooltip.Trigger asChild>
<Button className='size-[28px] rounded-md p-0' variant='active'>
<Button className='size-[28px] rounded-sm p-0' variant='active'>
{mode === 'hand' ? (
<Hand className='size-[14px]' />
) : (
@@ -106,9 +117,12 @@ export const WorkflowControls = memo(function WorkflowControls() {
)}
</Button>
</Tooltip.Trigger>
<Button className='-m-1 !p-1.5 group' variant='ghost'>
<Button
variant='ghost'
className={cn('size-[20px] rounded-sm p-0', chipHoverSurfaceClass)}
>
<ChevronDown
className={`size-[14px] text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${isCanvasModeOpen ? 'rotate-180' : ''}`}
className={cn(disclosureChevronClass, isCanvasModeOpen && 'rotate-180')}
/>
</Button>
</div>
@@ -122,7 +136,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
setIsCanvasModeOpen(false)
}}
>
<Hand className='size-3' />
<Hand className='size-[14px]' />
<span>Mover</span>
</PopoverItem>
<PopoverItem
@@ -131,7 +145,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
setIsCanvasModeOpen(false)
}}
>
<Cursor className='size-3' />
<Cursor className='size-[14px]' />
<span>Pointer</span>
</PopoverItem>
</PopoverContent>
@@ -143,11 +157,11 @@ export const WorkflowControls = memo(function WorkflowControls() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='size-[28px] rounded-md p-0 hover-hover:bg-[var(--surface-5)]'
className={cn('size-[28px] rounded-sm p-0', chipHoverSurfaceClass)}
onClick={undo}
disabled={!canUndo}
>
<Undo className='size-[16px]' />
<Undo className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -159,11 +173,11 @@ export const WorkflowControls = memo(function WorkflowControls() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='size-[28px] rounded-md p-0 hover-hover:bg-[var(--surface-5)]'
className={cn('size-[28px] rounded-sm p-0', chipHoverSurfaceClass)}
onClick={redo}
disabled={!canRedo}
>
<Redo className='size-[16px]' />
<Redo className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -177,10 +191,10 @@ export const WorkflowControls = memo(function WorkflowControls() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='size-[28px] rounded-md p-0 hover-hover:bg-[var(--surface-5)]'
className={cn('size-[28px] rounded-sm p-0', chipHoverSurfaceClass)}
onClick={handleFitToView}
>
<SelectAll className='size-[16px]' />
<SelectAll className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -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<HTMLElement>('*')].some((el) =>
+28 -8
View File
@@ -9,14 +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 `<body>`, 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.
*
* 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.
*/
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,
@@ -91,6 +107,8 @@ export function useDragResize(options: UseDragResizeOptions) {
const pointerId = e.pointerId
const { cssVar } = optionsRef.current
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)
@@ -100,7 +118,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 +162,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?.()
}
@@ -46,6 +46,7 @@ export const chipFieldTextClass =
* Like every token in this module, never re-derive the literal; import it.
*/
export const chipContentGap = 'gap-1.5'
/**
* Chip pill geometry — height, centering, gap, radius, padding, text size — with
* NO interactivity (no `cursor-pointer`, no hover). `chipVariants` composes this
@@ -58,6 +59,20 @@ export const chipGeometryClass = `h-[30px] items-center ${chipContentGap} rounde
export const chipContentIconClass = 'size-[16px] flex-shrink-0 text-[var(--text-icon)]'
/** Chip-content label (non-inverse): truncating `--text-body` at `text-sm`. Inverse chip variants override the color to `currentColor`. */
export const chipContentLabelClass = 'min-w-0 truncate text-[var(--text-body)] text-sm'
/**
* The two row surfaces. Mutually exclusive — a row paints one OR the other,
* never both, so a selected row holds its surface through hover.
*
* Hover used to be `--surface-active` (a hovered row looked selected, so lists
* appeared to have two selections) and active used to brighten to `--surface-6`
* on hover (read as the selection changing under the cursor). Do not reintroduce
* either. `chipVariants` wires this for pills; hand-rolled rows import these
* rather than restating the literals.
*/
export const chipHoverSurfaceClass = 'hover-hover:bg-[var(--surface-hover)]'
/** @see {@link chipHoverSurfaceClass} — the selected half of the same pair. */
export const chipActiveSurfaceClass = 'bg-[var(--surface-active)]'
/**
* The disclosure chevron that rotates to expand or collapse a sidebar section or a
* tree row: 14px at `--text-icon`, animating on the same 150ms curve the section
+12 -27
View File
@@ -11,10 +11,12 @@ import { cva, type VariantProps } from 'class-variance-authority'
import Link, { type LinkProps } from 'next/link'
import { cn } from '../../lib/cn'
import {
chipActiveSurfaceClass,
chipContentIconClass,
chipContentLabelClass,
chipFilledFillTokens,
chipGeometryClass,
chipHoverSurfaceClass,
chipPrimaryFillTokens,
} from './chip-chrome'
@@ -27,16 +29,16 @@ import {
* - `chipVariants({...})` → any other element (`<div role='button'>`, `<DropdownMenuTrigger asChild>` 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 +46,12 @@ 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`).
* The two surfaces themselves, and why an active chip takes no hover class at all, are documented on
* {@link chipHoverSurfaceClass}.
*/
const chipVariants = cva(
`group cursor-pointer ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
@@ -61,33 +65,14 @@ const chipVariants = cva(
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
'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)]',
border: `shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] ${chipHoverSurfaceClass} 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' },
},
compoundVariants: [
{
variant: 'default',
active: false,
className: 'hover-hover:bg-[var(--surface-active)]',
},
{
variant: 'default',
active: true,
className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
},
{
variant: 'filled',
active: false,
className: 'hover-hover:bg-[var(--surface-active)]',
},
{
variant: 'filled',
active: true,
className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
},
{ variant: ['default', 'filled'], active: false, className: chipHoverSurfaceClass },
{ variant: ['default', 'filled'], active: true, className: chipActiveSurfaceClass },
],
defaultVariants: { variant: 'default', active: false, fullWidth: false },
}
@@ -17,6 +17,7 @@ import {
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, ChevronDown, Loader, Search } from '../../icons'
import { cn } from '../../lib/cn'
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
@@ -841,8 +842,17 @@ 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)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
/*
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'
)}
>
@@ -881,8 +891,10 @@ 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)]'
// Clears the highlight rather than taking it, so unlike option rows it hovers.
!multiSelectValues?.length
? chipActiveSurfaceClass
: chipHoverSurfaceClass
)}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
@@ -915,8 +927,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)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
// See above: `isHighlighted` alone, so paint matches what Enter commits.
(isHighlighted || isSelected) && chipActiveSurfaceClass,
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
+2
View File
@@ -19,6 +19,7 @@ export {
export { ChipChevronDown } from './chip/chip-chevron'
export {
cellIconNodeClass,
chipActiveSurfaceClass,
chipBorderShadowRing,
chipContentGap,
chipContentIconClass,
@@ -28,6 +29,7 @@ export {
chipFilledFillTokens,
chipFilledSurfaceTokens,
chipGeometryClass,
chipHoverSurfaceClass,
chipIconSlotClass,
chipPrimaryFillTokens,
disclosureChevronClass,
@@ -55,6 +55,7 @@ import * as PopoverPrimitive from '@radix-ui/react-popover'
import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, Search } from '../../icons'
import { cn } from '../../lib/cn'
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
type PopoverSize = 'sm' | 'md'
type PopoverColorScheme = 'default' | 'inverted'
@@ -115,8 +116,13 @@ const STYLES = {
/** Interactive state styles: default, and inverted (dark bg in light mode) */
states: {
default: {
active: 'bg-[var(--surface-active)]',
hover: 'hover-hover:bg-[var(--surface-active)]',
/**
* The shared row-state pair see {@link chipHoverSurfaceClass}.
* `getItemStateClasses` returns active OR hover and never both, which is
* what holds a checked item's surface through hover.
*/
active: chipActiveSurfaceClass,
hover: chipHoverSurfaceClass,
},
inverted: {
active:
+32 -2
View File
@@ -33,6 +33,23 @@ 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
* the same one the canvas controls keep, 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 `<body>` 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 = 12
/** 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. */
@@ -590,14 +607,27 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
aria-live='polite'
aria-label='Notifications'
data-native-surface-overlay=''
/*
* The stack is portalled to `<body>`, 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,
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) + var(--workspace-content-gap, 0px) + ${WORKFLOW_INSET_PX}px)`
: `${VIEWPORT_INSET_PX}px`,
bottom: isWorkflowPage
? `calc(var(--terminal-height) + var(--workspace-content-gap, 0px) + ${WORKFLOW_INSET_PX}px)`
: `${VIEWPORT_INSET_PX}px`,
width: TOAST_WIDTH,
height: containerHeight,
}}