From 56e3a8584a067749e7699f09fceded514758d4b3 Mon Sep 17 00:00:00 2001 From: Dominika Pacholec Date: Mon, 5 Oct 2026 12:53:39 +0200 Subject: [PATCH 1/7] feat(ai-studio): the execution log follows the design prototype --- .../execution-status-icon.module.css | 82 ++++++ .../execution/execution-status-icon.tsx | 92 +++++++ .../src/components/execution/highlighting.css | 10 + .../components/execution/log-panel.module.css | 245 +++++++++--------- .../components/execution/log-panel.test.tsx | 93 ++++++- .../src/components/execution/log-panel.tsx | 199 ++++++++++---- .../src/hooks/use-left-panel-anchor.ts | 61 +++++ .../src/hooks/use-right-panel-anchor.ts | 2 +- 8 files changed, 606 insertions(+), 178 deletions(-) create mode 100644 apps/ai-studio/src/components/execution/execution-status-icon.module.css create mode 100644 apps/ai-studio/src/components/execution/execution-status-icon.tsx create mode 100644 apps/ai-studio/src/hooks/use-left-panel-anchor.ts diff --git a/apps/ai-studio/src/components/execution/execution-status-icon.module.css b/apps/ai-studio/src/components/execution/execution-status-icon.module.css new file mode 100644 index 000000000..d2a4fb239 --- /dev/null +++ b/apps/ai-studio/src/components/execution/execution-status-icon.module.css @@ -0,0 +1,82 @@ +.icon { + display: inline-grid; + place-items: center; + flex-shrink: 0; + border-radius: var(--wb-ds-radius-full); + color: var(--ai-studio-status-color--tone); + background: color-mix(in srgb, var(--ai-studio-status-color--tone), transparent 88%); + + /* Phosphor sizes by width/height attributes, which a class rule overrides. */ + svg { + width: 70%; + height: 70%; + } +} + +.s { + /* missing token */ + width: 0.9375rem; + height: 0.9375rem; +} + +.m { + width: var(--wb-ds-size-250); + height: var(--wb-ds-size-250); +} + +.running { + --ai-studio-status-color--tone: var(--ai-studio-status-color--running); +} + +.completed { + --ai-studio-status-color--tone: var(--ai-studio-status-color--completed); +} + +.waiting { + --ai-studio-status-color--tone: var(--ai-studio-status-color--human); +} + +.failed { + --ai-studio-status-color--tone: var(--ai-studio-status-color--failed); +} + +.warning { + --ai-studio-status-color--tone: var(--ai-studio-status-color--incomplete); +} + +.incomplete { + --ai-studio-status-color--tone: var(--ai-studio-status-color--incomplete); +} + +.branch, +.join, +.info { + --ai-studio-status-color--tone: var(--ai-studio-status-color--running); +} + +.skipped, +.neutral { + --ai-studio-status-color--tone: var(--wb-ds-ui-text-muted-default); +} + +.dot { + width: 30%; + height: 30%; + border-radius: var(--wb-ds-radius-full); + background: currentColor; +} + +/* Phosphor draws the split and merge top to bottom; the diagram flows left to right. */ +.rightward { + transform: rotate(-90deg); +} + +.spin { + animation: spin 1.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/apps/ai-studio/src/components/execution/execution-status-icon.tsx b/apps/ai-studio/src/components/execution/execution-status-icon.tsx new file mode 100644 index 000000000..4df46c656 --- /dev/null +++ b/apps/ai-studio/src/components/execution/execution-status-icon.tsx @@ -0,0 +1,92 @@ +import { + ArrowsMerge, + ArrowsSplit, + Check, + Clock, + ExclamationMark, + LinkBreak, + SkipForward, + Spinner, +} from '@phosphor-icons/react'; +import clsx from 'clsx'; + +import styles from './execution-status-icon.module.css'; + +export type ExecutionStatusTone = + | 'running' + | 'completed' + | 'waiting' + | 'failed' + | 'warning' + | 'skipped' + | 'incomplete' + | 'branch' + | 'join' + | 'info' + | 'neutral'; + +type Props = { + tone: ExecutionStatusTone; + /** + * `s` is the execution log row (15 px), `m` the node header (20 px). + * @default 's' + */ + size?: 's' | 'm'; + className?: string; +}; + +// A bare "i" in Phosphor's 256 grid and bold stroke; a text glyph sits on the font's baseline, off centre. +function InfoGlyph() { + return ( + + + + + ); +} + +// Phosphor, not the SDK `Icon`: that one sets its size inline, so the glyphs would not follow the badge. +function Glyph({ tone }: { tone: ExecutionStatusTone }) { + switch (tone) { + case 'running': { + return ; + } + case 'completed': { + return ; + } + case 'waiting': { + return ; + } + case 'failed': + case 'warning': { + return ; + } + case 'skipped': { + return ; + } + case 'incomplete': { + return ; + } + case 'branch': { + return ; + } + case 'join': { + return ; + } + case 'info': { + return ; + } + case 'neutral': { + return ; + } + } +} + +// One status language for the node header and the execution log rows. +export function ExecutionStatusIcon({ tone, size = 's', className }: Props) { + return ( + + ); +} diff --git a/apps/ai-studio/src/components/execution/highlighting.css b/apps/ai-studio/src/components/execution/highlighting.css index d44c4547e..5b34f7ccb 100644 --- a/apps/ai-studio/src/components/execution/highlighting.css +++ b/apps/ai-studio/src/components/execution/highlighting.css @@ -1,11 +1,19 @@ html[data-theme='light'] { --ai-studio-edge-color--active: var(--wb-ds-colors-green-400); --ai-studio-status-color--completed: var(--wb-ds-colors-green-400); + /* missing token */ + --ai-studio-status-color--running: var(--wb-ds-colors-acc1-500); + /* missing token */ + --ai-studio-status-color--human: var(--wb-ds-colors-acc4-700); } html[data-theme='dark'] { --ai-studio-edge-color--active: var(--wb-ds-colors-green-200); --ai-studio-status-color--completed: var(--wb-ds-colors-green-200); + /* missing token */ + --ai-studio-status-color--running: var(--wb-ds-colors-acc1-400); + /* missing token */ + --ai-studio-status-color--human: var(--wb-ds-colors-acc4-300); } :root { @@ -15,6 +23,8 @@ html[data-theme='dark'] { --ai-studio-status-bg--completed: color-mix(in srgb, var(--ai-studio-status-color--completed), transparent 85%); --ai-studio-status-bg--waiting: color-mix(in srgb, var(--ai-studio-status-color--waiting), transparent 85%); --ai-studio-status-bg--incomplete: color-mix(in srgb, var(--ai-studio-status-color--incomplete), transparent 85%); + --ai-studio-status-bg--running: color-mix(in srgb, var(--ai-studio-status-color--running), transparent 85%); + --ai-studio-status-bg--human: color-mix(in srgb, var(--ai-studio-status-color--human), transparent 85%); --ai-studio-status-bg--failed: color-mix(in srgb, var(--ai-studio-status-color--failed), transparent 85%); --ai-studio-node-shadow-color--active: var(--ai-studio-edge-color--active); diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index 7e0819777..0d25abdee 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -1,149 +1,143 @@ -.panel { +.dock { position: fixed; - bottom: 1rem; + bottom: var(--wb-ds-space-150); /* stylelint-disable-next-line csstools/value-no-unknown-custom-properties -- set from log-panel.tsx inline style */ - right: var(--log-panel-right, 1rem); - width: 30rem; - max-width: calc(100vw - 3rem); - max-height: 26.875rem; - background: var(--wb-sdk-app-bar-background); - border: 0.0625rem solid var(--wb-sdk-app-bar-border-color); - border-radius: var(--wb-sdk-app-bar-border-radius); - color: var(--wb-ds-ui-text-default); + left: var(--log-dock-left, var(--wb-ds-space-150)); + /* stylelint-disable-next-line csstools/value-no-unknown-custom-properties -- set from log-panel.tsx inline style */ + right: var(--log-dock-right, var(--wb-ds-space-150)); + z-index: 10; display: flex; flex-direction: column; - z-index: 10; - font-size: 0.75rem; - font-family: var(--wb-public-font-family, 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif); + min-width: 0; + container-type: inline-size; + background: var(--wb-ds-ui-bg-base); + border: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-default); + border-radius: var(--wb-ds-radius-150); + box-shadow: var(--wb-ds-shadow-ui-m-x) var(--wb-ds-shadow-ui-m-y) var(--wb-ds-shadow-ui-m-blur) + var(--wb-ds-shadow-ui-m-spread) var(--wb-ds-shadow-ui-color); + color: var(--wb-ds-ui-text-default); overflow: hidden; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); -} - -.panel--collapsed { - max-height: unset; } .header { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; + gap: var(--wb-ds-space-100); + min-height: 2.625rem; + padding: var(--wb-ds-space-75) var(--wb-ds-space-150) var(--wb-ds-space-75) var(--wb-ds-space-75); + border-bottom: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-divider); cursor: pointer; user-select: none; - border-bottom: 0.0625rem solid var(--wb-sdk-app-bar-border-color); flex-shrink: 0; } -.panel--collapsed .header { +.dock--collapsed .header { border-bottom: none; } -.title { - font-weight: 600; - font-size: 0.75rem; - flex: 1; +.collapse svg { + transition: transform 0.15s; } -.toggle { - font-size: 0.6rem; - opacity: 0.5; +.dock--collapsed .collapse svg { + transform: rotate(180deg); } -.body { - overflow-y: auto; - flex: 1; - padding: 0.25rem 0; +.chip { + color: var(--ai-studio-status-color--tone); + background: color-mix(in srgb, var(--ai-studio-status-color--tone), transparent 88%); } -.event { - padding: 0.375rem 0.75rem; - border-bottom: 0.0625rem solid color-mix(in srgb, var(--wb-sdk-app-bar-border-color), transparent 50%); - - &:last-child { - border-bottom: none; - } +.chip--running { + --ai-studio-status-color--tone: var(--ai-studio-status-color--running); } -.event--toggleable { - cursor: pointer; +.chip--completed { + --ai-studio-status-color--tone: var(--ai-studio-status-color--completed); } -.event-header { - display: flex; - align-items: center; - gap: 0.375rem; +.chip--waiting { + --ai-studio-status-color--tone: var(--ai-studio-status-color--human); } -.badge { - font-size: 0.65rem; - font-weight: 600; - padding: 0.1rem 0.35rem; - border-radius: 0.25rem; - background: color-mix(in srgb, currentColor, transparent 85%); - text-transform: uppercase; - letter-spacing: 0.03em; - white-space: nowrap; +.chip--failed { + --ai-studio-status-color--tone: var(--ai-studio-status-color--failed); } -.badge--execution_started, -.badge--execution_completed, -.badge--node_completed { - color: var(--ai-studio-status-color--completed); +.chip--warning, +.chip--incomplete { + --ai-studio-status-color--tone: var(--ai-studio-status-color--incomplete); } -.badge--node_started, -.badge--node_skipped { - color: var(--wb-ds-ui-text-subtle-default); +.chip--branch, +.chip--join, +.chip--skipped, +.chip--info, +.chip--neutral { + --ai-studio-status-color--tone: var(--wb-ds-ui-text-subtle-default); } -.badge--node_waiting { - color: var(--ai-studio-status-color--waiting); +.body { + height: 12.5rem; + overflow-y: auto; + padding: var(--wb-ds-space-75) var(--wb-ds-space-100) var(--wb-ds-space-100); } -.badge--execution_incomplete { - color: var(--ai-studio-status-color--incomplete); +.row { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: start; + column-gap: var(--wb-ds-space-112); + row-gap: var(--wb-ds-space-25); + padding: var(--wb-ds-space-62) var(--wb-ds-space-100); + border-radius: var(--wb-ds-radius-100); + line-height: 1.45; + + &:hover { + background: var(--wb-ds-ui-bg-fill-hover); + } } -.badge--node_failed, -.badge--execution_failed { - color: var(--ai-studio-status-color--failed); +.row--toggleable { + cursor: pointer; } -.badge--execution_cancelled { - color: var(--wb-ds-ui-text-subtle-default); +.time { + font-family: var(--wb-public-font-family-mono, ui-monospace, 'Cascadia Mono', monospace); + font-size: 0.625rem; + font-variant-numeric: tabular-nums; + color: var(--wb-ds-ui-text-muted-default); + padding-top: 0.125rem; } -.node-id { - font-family: monospace; - font-size: 0.65rem; - opacity: 0.5; +.status { + margin-top: 0.0625rem; } -.reason { - font-size: 0.65rem; - font-style: italic; - opacity: 0.55; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.title, +.message { + min-width: 0; + overflow-wrap: anywhere; } -.time { - margin-left: auto; - font-size: 0.65rem; - opacity: 0.45; - white-space: nowrap; +.message { + grid-column: 3; + color: var(--wb-ds-ui-text-subtle-default); + white-space: pre-line; } .detail { - margin-top: 0.25rem; - padding: 0.375rem 0.5rem; - background: color-mix(in srgb, var(--wb-sdk-app-bar-border-color), transparent 70%); - border-radius: 0.25rem; + grid-column: 3; + margin-top: var(--wb-ds-space-25); + padding: var(--wb-ds-space-75) var(--wb-ds-space-112); + background: var(--wb-ds-ui-bg-inset); + border: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-subtle); + border-radius: var(--wb-ds-radius-100); + font-family: var(--wb-public-font-family-mono, ui-monospace, 'Cascadia Mono', monospace); + font-size: 0.625rem; + color: var(--wb-ds-ui-text-subtle-default); white-space: pre-wrap; word-break: break-word; - line-height: 1.5; - font-size: 0.7rem; } .detail--expanded { @@ -151,46 +145,47 @@ overflow-y: auto; } -.status { - font-size: 0.65rem; - font-weight: 500; - padding: 0.1rem 0.35rem; - border-radius: 0.25rem; +.row--highlighted, +.row--highlighted:hover { + background: var(--wb-ds-ui-bg-selected); } -.status--idle { - opacity: 0.4; -} +.row--failed, +.row--failed:hover { + background: var(--ai-studio-status-bg--failed); + box-shadow: inset 0.1875rem 0 0 var(--ai-studio-status-color--failed); -.status--pending, -.status--running, -.status--completed { - color: var(--ai-studio-status-color--completed); - background: var(--ai-studio-status-bg--completed); + .title { + color: var(--ai-studio-status-color--failed); + font-weight: 700; + } } -.status--waiting { - color: var(--ai-studio-status-color--waiting); - background: var(--ai-studio-status-bg--waiting); +.row--failed.row--highlighted { + box-shadow: + inset 0.1875rem 0 0 var(--ai-studio-status-color--failed), + 0 0 0 var(--wb-ds-size-12) var(--wb-ds-ui-stroke-focus); } -.status--incomplete, -.status--disconnected { - color: var(--ai-studio-status-color--incomplete); - background: var(--ai-studio-status-bg--incomplete); -} +/* Wide dock: time · status · block · message on one line, detail below. */ +@container (min-width: 51.25rem) { + .row { + grid-template-columns: 4rem auto minmax(11.25rem, 16.25rem) 1fr; + column-gap: var(--wb-ds-space-150); + } -.status--failed { - color: var(--ai-studio-status-color--failed); - background: var(--ai-studio-status-bg--failed); -} + .title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } -.status--cancelled, -.status--cancelling { - opacity: 0.5; -} + .message { + grid-column: 4; + grid-row: 1; + } -.event--highlighted { - background: color-mix(in srgb, var(--wb-ds-colors-acc1-500), transparent 88%); - box-shadow: inset 0.1875rem 0 0 var(--wb-ds-colors-acc1-500); + .detail { + grid-column: 3 / -1; + } } diff --git a/apps/ai-studio/src/components/execution/log-panel.test.tsx b/apps/ai-studio/src/components/execution/log-panel.test.tsx index 51fefd93f..12bc52b21 100644 --- a/apps/ai-studio/src/components/execution/log-panel.test.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.test.tsx @@ -8,9 +8,20 @@ import { executionEvent as event } from '../../stores/execution-event.fixture'; import { applyEvent, resetExecution, setExecutionStarted } from '../../stores/use-execution-store'; import { ExecutionLogPanel } from './log-panel'; +const sdk = vi.hoisted(() => ({ + selectedNodeId: null as string | null, + nodes: [] as { id: string; data: { properties: { label?: string } } }[], + edges: [] as { source: string; target: string }[], +})); + vi.mock('@workflowbuilder/sdk', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useSingleSelectedElement: () => null }; + return { + ...actual, + useSingleSelectedElement: () => (sdk.selectedNodeId ? { node: { id: sdk.selectedNodeId } } : null), + useStore: (selector: (state: Pick) => unknown) => + selector({ nodes: sdk.nodes, edges: sdk.edges }), + }; }); declare global { @@ -24,6 +35,17 @@ describe('ExecutionLogPanel', () => { let root: ReturnType; beforeEach(() => { + sdk.selectedNodeId = null; + sdk.nodes = [ + { id: 'classify-1', data: { properties: { label: 'Classify ticket' } } }, + { id: 'region-1', data: { properties: { label: 'Shipping region' } } }, + { id: 'card-1', data: { properties: { label: 'Charge card' } } }, + { id: 'stock-1', data: { properties: { label: 'Reserve stock' } } }, + ]; + sdk.edges = [ + { source: 'classify-1', target: 'card-1' }, + { source: 'classify-1', target: 'stock-1' }, + ]; resetExecution(); setExecutionStarted('exec-1', '/stream'); container = document.createElement('div'); @@ -43,6 +65,8 @@ describe('ExecutionLogPanel', () => { }); } + const nodeRow = (nodeId: string) => container.querySelector(`[data-node-id="${nodeId}"]`); + it('names the outcome, who settled it and where, on the execution completed row', () => { renderAfter( event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }), @@ -61,7 +85,72 @@ describe('ExecutionLogPanel', () => { event({ type: 'execution_completed' }), ); - expect(container.textContent).toContain('execution completed'); + expect(container.textContent).toContain('Execution completed'); expect(container.textContent).not.toContain('resolved by'); }); + + it('names a node row by the label from the diagram, not by its id', () => { + renderAfter(event({ type: 'node_started', nodeId: 'classify-1' })); + + expect(nodeRow('classify-1')?.textContent).toContain('Classify ticket'); + expect(nodeRow('classify-1')?.textContent).not.toContain('classify-1'); + }); + + it('falls back to the node id when the diagram no longer has the node', () => { + renderAfter(event({ type: 'node_started', nodeId: 'gone-1' })); + + expect(nodeRow('gone-1')?.textContent).toContain('gone-1'); + }); + + it('shows the run status in the header chip', () => { + renderAfter( + event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }), + event({ type: 'execution_failed', payload: { error: { message: 'boom' } } }), + ); + + expect(container.querySelector('[class*="header"]')?.textContent).toContain('Failed'); + }); + + it('marks a failed node row as an error', () => { + renderAfter(event({ type: 'node_failed', nodeId: 'classify-1', payload: { error: { message: 'boom' } } })); + + expect(nodeRow('classify-1')?.className).toMatch(/row--failed/); + }); + + it('highlights the rows of the node selected on the canvas', () => { + sdk.selectedNodeId = 'classify-1'; + renderAfter(event({ type: 'node_started', nodeId: 'classify-1' })); + + expect(nodeRow('classify-1')?.className).toMatch(/row--highlighted/); + }); + + it('names the parallel branches a node started, from the diagram', () => { + renderAfter(event({ type: 'branch_spawned', nodeId: 'classify-1', payload: { childPathIds: ['p1', 'p2'] } })); + + expect(nodeRow('classify-1')?.textContent).toContain('Started 2 parallel branches → Charge card · Reserve stock'); + }); + + it('says how many inputs a join received', () => { + renderAfter(event({ type: 'branches_joined', nodeId: 'stock-1', payload: { mergedPathIds: ['p1', 'p2'] } })); + + expect(nodeRow('stock-1')?.textContent).toContain('Branches joined — 2 inputs arrived · continuing'); + }); + + it('tells a skipped row why the node did not run', () => { + renderAfter(event({ type: 'node_skipped', nodeId: 'card-1', payload: { reason: 'branch_not_taken' } })); + + expect(nodeRow('card-1')?.textContent).toContain('Skipped — branch not taken'); + }); + + it('names the step and the output with no connection on an incomplete run', () => { + renderAfter( + event({ type: 'execution_started', payload: { workflowId: 'wf-1' } }), + event({ type: 'execution_incomplete', payload: { deadEnds: [{ nodeId: 'region-1', port: 'Outside EU' }] } }), + ); + + expect(container.textContent).toContain( + 'Shipping region took “Outside EU”, an output with no connection. Draw the missing connection to finish this path.', + ); + expect(container.querySelector('[class*="header"]')?.textContent).toContain('Incomplete'); + }); }); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index a6771f197..3051b3bce 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -1,4 +1,5 @@ -import { useSingleSelectedElement } from '@workflowbuilder/sdk'; +import { Icon, useSingleSelectedElement, useStore } from '@workflowbuilder/sdk'; +import { Chip, NavButton } from '@workflowbuilder/ui'; import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; @@ -6,9 +7,11 @@ import type { ExecutionEvent, NodeSkipReason } from '@workflow-builder/types/wor import styles from './log-panel.module.css'; +import { useLeftPanelAnchor } from '../../hooks/use-left-panel-anchor'; import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor'; -import { toggleLog, useExecutionStore } from '../../stores/use-execution-store'; +import { type RunStatus, toggleLog, useExecutionStore } from '../../stores/use-execution-store'; import { extractOutputText } from '../../utils/extract-output-text'; +import { ExecutionStatusIcon, type ExecutionStatusTone } from './execution-status-icon'; const SKIP_REASON_LABEL: Record = { branch_not_taken: 'branch not taken', @@ -16,56 +19,119 @@ const SKIP_REASON_LABEL: Record = { error_route_not_taken: 'error branch not taken', }; +const EVENT_LOOK: Record = { + execution_started: { tone: 'info', message: 'Execution started' }, + node_started: { tone: 'info', message: 'Started' }, + node_waiting: { tone: 'waiting', message: 'Waiting for decision' }, + node_completed: { tone: 'completed', message: 'Completed' }, + node_failed: { tone: 'failed', message: 'Failed' }, + node_skipped: { tone: 'skipped', message: 'Skipped' }, + branch_spawned: { tone: 'branch', message: 'Started parallel branches' }, + branches_joined: { tone: 'join', message: 'Branches joined' }, + execution_completed: { tone: 'completed', message: 'Execution completed' }, + execution_incomplete: { tone: 'incomplete', message: 'Incomplete' }, + execution_failed: { tone: 'failed', message: 'Execution failed' }, + execution_cancelled: { tone: 'neutral', message: 'Execution cancelled' }, +}; + +type DiagramState = { + nodes: { id: string; data: { properties: { label?: unknown } } }[]; + edges: { source: string; target: string }[]; +}; + +function nodeLabel(state: DiagramState, nodeId: string) { + const label = state.nodes.find((node) => node.id === nodeId)?.data.properties.label; + return typeof label === 'string' && label ? label : nodeId; +} + +// Branch names and join counts come from the diagram until the events carry them (follow-up: execution-log-branch-payloads). +function eventMessage(event: ExecutionEvent, state: DiagramState): string { + const { message } = EVENT_LOOK[event.type]; + switch (event.type) { + case 'node_skipped': { + return `${message} — ${SKIP_REASON_LABEL[event.payload.reason]}`; + } + case 'branch_spawned': { + const count = event.payload.childPathIds.length; + const targets = state.edges.filter((edge) => edge.source === event.nodeId).map((edge) => edge.target); + const names = targets.length === count ? ` → ${targets.map((id) => nodeLabel(state, id)).join(' · ')}` : ''; + return `Started ${count} parallel branches${names}`; + } + case 'branches_joined': { + return `${message} — ${event.payload.mergedPathIds.length} inputs arrived · continuing`; + } + case 'execution_incomplete': { + return event.payload.deadEnds + .map( + ({ nodeId, port }) => + `${nodeLabel(state, nodeId)} took “${port}”, an output with no connection. Draw the missing connection to finish this path.`, + ) + .join('\n'); + } + default: { + return message; + } + } +} + +const RUN_STATUS_LOOK: Record = { + idle: { tone: 'neutral', label: 'Idle' }, + pending: { tone: 'running', label: 'Starting' }, + running: { tone: 'running', label: 'Running' }, + waiting: { tone: 'waiting', label: 'Waiting for decision' }, + cancelling: { tone: 'neutral', label: 'Stopping' }, + completed: { tone: 'completed', label: 'Completed' }, + incomplete: { tone: 'incomplete', label: 'Incomplete' }, + failed: { tone: 'failed', label: 'Failed' }, + cancelled: { tone: 'neutral', label: 'Cancelled' }, + disconnected: { tone: 'warning', label: 'Disconnected' }, +}; + const DETAIL_PREVIEW_CHARS = 120; -const NODE_ID_PREVIEW_CHARS = 8; const AT_BOTTOM_TOLERANCE_PX = 4; function formatTime(isoTimestamp: string) { return new Date(isoTimestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } -function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNodeId: string | null }) { - const [isExpanded, setIsExpanded] = useState(false); - - const nodeId = (event as { nodeId?: string | null }).nodeId; - const isNode = typeof nodeId === 'string' && nodeId.length > 0; - const isHighlighted = isNode && nodeId === selectedNodeId; - const label = event.type.replaceAll('_', ' '); - const skipReason = event.type === 'node_skipped' ? SKIP_REASON_LABEL[event.payload.reason] : undefined; - - let detail: string | undefined; +function eventDetail(event: ExecutionEvent): string | undefined { switch (event.type) { case 'node_completed': { - detail = extractOutputText(event.payload.output); - - break; - } - case 'node_failed': { - detail = event.payload.error.message; - - break; + return extractOutputText(event.payload.output); } + case 'node_failed': case 'execution_failed': { - detail = event.payload.error.message; - - break; + return event.payload.error.message; } case 'execution_completed': { const outcome = event.payload?.outcome; - if (outcome) detail = `${outcome.value} · resolved by ${outcome.resolvedBy} · ${outcome.nodeId}`; - - break; + return outcome ? `${outcome.value} · resolved by ${outcome.resolvedBy} · ${outcome.nodeId}` : undefined; } case 'execution_incomplete': { - detail = event.payload.deadEnds + return event.payload.deadEnds .map(({ nodeId, port }) => `${nodeId} routed to "${port}" — nothing connected to that handle`) .join('\n'); - - break; } - // No default + default: { + return undefined; + } } +} + +function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNodeId: string | null }) { + const [isExpanded, setIsExpanded] = useState(false); + const nodeId = (event as { nodeId?: string | null }).nodeId; + const isNode = typeof nodeId === 'string' && nodeId.length > 0; + const isHighlighted = isNode && nodeId === selectedNodeId; + const { tone, message } = EVENT_LOOK[event.type]; + // Each selector returns a string, so the row re-renders only when its text changes. + const title = useStore((state) => (isNode ? nodeLabel(state, nodeId) : message)); + const subtitle = useStore((state) => + isNode || event.type === 'execution_incomplete' ? eventMessage(event, state) : undefined, + ); + + const detail = eventDetail(event); const hasDetail = !!detail; const truncated = detail && detail.length > DETAIL_PREVIEW_CHARS ? detail.slice(0, DETAIL_PREVIEW_CHARS) + '…' : detail; @@ -82,19 +148,17 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo return (
-
- {label} - {isNode && {nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}} - {skipReason && {skipReason}} - {formatTime(event.timestamp)} - {hasDetail && {isExpanded ? '▲' : '▼'}} -
+ {formatTime(event.timestamp)} + + {title} + {subtitle && {subtitle}} {hasDetail && (
{isExpanded ? detail : truncated} @@ -112,6 +176,7 @@ export function ExecutionLogPanel() { // Clicking a node (incl. its flag marker) selects it on the canvas; the // highlight derives from that selection, so it clears on deselect. const selectedNodeId = useSingleSelectedElement()?.node?.id ?? null; + const { leftOffset } = useLeftPanelAnchor(); const { rightOffset } = useRightPanelAnchor(); const bodyRef = useRef(null); @@ -129,7 +194,13 @@ export function ExecutionLogPanel() { useEffect(() => { if (!selectedNodeId || isCollapsed) return; - bodyRef.current?.querySelector(`[data-node-id="${selectedNodeId}"]`)?.scrollIntoView({ block: 'nearest' }); + const body = bodyRef.current; + const row = body?.querySelector(`[data-node-id="${selectedNodeId}"]`); + if (!body || !row) return; + + // The first row goes to the top so the node's later rows fit below it; scrollIntoView would also scroll the page. + const paddingTop = Number.parseFloat(getComputedStyle(body).paddingTop) || 0; + body.scrollTop += row.getBoundingClientRect().top - body.getBoundingClientRect().top - paddingTop; }, [selectedNodeId, isCollapsed]); function handleBodyScroll() { @@ -140,17 +211,45 @@ export function ExecutionLogPanel() { stickToBottomRef.current = distanceFromBottom < AT_BOTTOM_TOLERANCE_PX; } + function handleHeaderClick({ target }: React.MouseEvent) { + if (target instanceof Element && target.closest('button')) return; + toggleLog(); + } + if (events.length === 0 && status === 'idle') return null; + const runStatus = RUN_STATUS_LOOK[status]; + const collapseLabel = isCollapsed ? 'Expand execution log' : 'Collapse execution log'; + return ( -
-
- Execution Log - {status} - {isCollapsed ? '▲' : '▼'} +
+ } + /> + Execution log + : undefined} + className={clsx(styles['chip'], styles[`chip--${runStatus.tone}`])} + />
{!isCollapsed && (
@@ -159,6 +258,6 @@ export function ExecutionLogPanel() { ))}
)} -
+ ); } diff --git a/apps/ai-studio/src/hooks/use-left-panel-anchor.ts b/apps/ai-studio/src/hooks/use-left-panel-anchor.ts new file mode 100644 index 000000000..da1f81c4f --- /dev/null +++ b/apps/ai-studio/src/hooks/use-left-panel-anchor.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +const GAP_PX = 12; +const EXPANDED_HEIGHT_RATIO = 0.9; + +type LeftPanelAnchor = { + isPanelExpanded: boolean; + leftOffset: number; +}; + +const collapsedAnchor: LeftPanelAnchor = { isPanelExpanded: false, leftOffset: GAP_PX }; + +function findLeftPanel() { + const panel = document.querySelector('#viewport-bounds')?.previousElementSibling; + + return panel instanceof HTMLElement ? panel : undefined; +} + +function measureAnchor(panel: HTMLElement): LeftPanelAnchor { + const sidebar = panel.firstElementChild; + if (!(sidebar instanceof HTMLElement)) return collapsedAnchor; + + // Collapsed, the palette is a short pill at the top; the dock then runs to the canvas edge. + const isPanelExpanded = sidebar.offsetHeight >= panel.offsetHeight * EXPANDED_HEIGHT_RATIO; + if (!isPanelExpanded) return collapsedAnchor; + + return { isPanelExpanded: true, leftOffset: Math.round(sidebar.getBoundingClientRect().right) + GAP_PX }; +} + +function sameAnchor(current: LeftPanelAnchor, next: LeftPanelAnchor) { + return current.isPanelExpanded === next.isPanelExpanded && current.leftOffset === next.leftOffset; +} + +function observeAnchor(panel: HTMLElement, onMeasure: (next: LeftPanelAnchor) => void) { + const updateAnchor = () => onMeasure(measureAnchor(panel)); + + updateAnchor(); + + const observer = new ResizeObserver(updateAnchor); + observer.observe(panel); + window.addEventListener('resize', updateAnchor); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updateAnchor); + }; +} + +// The mirror of useRightPanelAnchor: the palette is the element before #viewport-bounds. +export function useLeftPanelAnchor(): LeftPanelAnchor { + const [anchor, setAnchor] = useState(collapsedAnchor); + + useEffect(() => { + const panel = findLeftPanel(); + if (!panel) return; + + return observeAnchor(panel, (next) => setAnchor((current) => (sameAnchor(current, next) ? current : next))); + }, []); + + return anchor; +} diff --git a/apps/ai-studio/src/hooks/use-right-panel-anchor.ts b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts index ac1227109..4347b5bf5 100644 --- a/apps/ai-studio/src/hooks/use-right-panel-anchor.ts +++ b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -const GAP_PX = 16; +const GAP_PX = 12; const EXPANDED_HEIGHT_RATIO = 0.9; type RightPanelAnchor = { From 8c8af4d9ef9551783e72c3190b195344263adaa1 Mon Sep 17 00:00:00 2001 From: Dominika Pacholec Date: Tue, 6 Oct 2026 09:21:43 +0200 Subject: [PATCH 2/7] fix(ai-studio): a log row keeps its detail on one line until it is expanded --- .../components/execution/log-panel.module.css | 11 +++++++---- .../src/components/execution/log-panel.test.tsx | 13 +++++++++++++ .../src/components/execution/log-panel.tsx | 16 ++++++++-------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index 0d25abdee..9edcb482c 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -126,6 +126,12 @@ white-space: pre-line; } +.message--clipped { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .detail { grid-column: 3; margin-top: var(--wb-ds-space-25); @@ -138,9 +144,6 @@ color: var(--wb-ds-ui-text-subtle-default); white-space: pre-wrap; word-break: break-word; -} - -.detail--expanded { max-height: 20rem; overflow-y: auto; } @@ -186,6 +189,6 @@ } .detail { - grid-column: 3 / -1; + grid-column: 1 / -1; } } diff --git a/apps/ai-studio/src/components/execution/log-panel.test.tsx b/apps/ai-studio/src/components/execution/log-panel.test.tsx index 12bc52b21..2b38f2498 100644 --- a/apps/ai-studio/src/components/execution/log-panel.test.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.test.tsx @@ -153,4 +153,17 @@ describe('ExecutionLogPanel', () => { ); expect(container.querySelector('[class*="header"]')?.textContent).toContain('Incomplete'); }); + + it('keeps the detail on the message line until the row is expanded', () => { + renderAfter(event({ type: 'node_failed', nodeId: 'classify-1', payload: { error: { message: 'boom' } } })); + const row = nodeRow('classify-1') as HTMLElement; + + expect(row.textContent).toContain('Failed — boom'); + expect(row.querySelector('[class*="detail"]')).toBeNull(); + + act(() => row.click()); + + expect(row.querySelector('[class*="detail"]')?.textContent).toBe('boom'); + expect(row.textContent).not.toContain('Failed — boom'); + }); }); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 3051b3bce..e4ec306c0 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -87,7 +87,6 @@ const RUN_STATUS_LOOK: Record DETAIL_PREVIEW_CHARS ? detail.slice(0, DETAIL_PREVIEW_CHARS) + '…' : detail; + // Collapsed, the detail rides on the message line, clipped; expanded, it opens in full below. + const preview = hasDetail && !isExpanded ? detail : undefined; + const line = [subtitle, preview].filter(Boolean).join(' — '); function handleToggle({ target }: React.MouseEvent) { const clickedInteractiveElement = target instanceof Element && !!target.closest('a, button'); @@ -158,12 +158,12 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo {formatTime(event.timestamp)} {title} - {subtitle && {subtitle}} - {hasDetail && ( -
- {isExpanded ? detail : truncated} -
+ {line && ( + + {line} + )} + {isExpanded &&
{detail}
}
); } From c9d70dc90195a8edcdc9864cecd594511aff0e60 Mon Sep 17 00:00:00 2001 From: Dominika Pacholec Date: Tue, 6 Oct 2026 09:37:36 +0200 Subject: [PATCH 3/7] fix(ai-studio): the execution log text and detail box match the prototype --- .../src/components/execution/highlighting.css | 4 ++++ .../src/components/execution/log-panel.module.css | 14 ++++++++++++-- .../src/components/execution/log-panel.tsx | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/ai-studio/src/components/execution/highlighting.css b/apps/ai-studio/src/components/execution/highlighting.css index 5b34f7ccb..f2463127b 100644 --- a/apps/ai-studio/src/components/execution/highlighting.css +++ b/apps/ai-studio/src/components/execution/highlighting.css @@ -5,6 +5,8 @@ html[data-theme='light'] { --ai-studio-status-color--running: var(--wb-ds-colors-acc1-500); /* missing token */ --ai-studio-status-color--human: var(--wb-ds-colors-acc4-700); + /* missing token */ + --ai-studio-log-detail-bg: var(--wb-ds-colors-gray-200); } html[data-theme='dark'] { @@ -14,6 +16,8 @@ html[data-theme='dark'] { --ai-studio-status-color--running: var(--wb-ds-colors-acc1-400); /* missing token */ --ai-studio-status-color--human: var(--wb-ds-colors-acc4-300); + /* missing token */ + --ai-studio-log-detail-bg: var(--wb-ds-colors-gray-800); } :root { diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index 9edcb482c..2d5cc00ee 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -35,6 +35,10 @@ border-bottom: none; } +.dock-title { + font-size: var(--wb-ds-font-size-162); +} + .collapse svg { transition: transform 0.15s; } @@ -118,10 +122,16 @@ .message { min-width: 0; overflow-wrap: anywhere; + line-height: 1.45; +} + +.title { + font-weight: 500; } .message { grid-column: 3; + font-size: var(--wb-ds-font-size-137); color: var(--wb-ds-ui-text-subtle-default); white-space: pre-line; } @@ -136,8 +146,8 @@ grid-column: 3; margin-top: var(--wb-ds-space-25); padding: var(--wb-ds-space-75) var(--wb-ds-space-112); - background: var(--wb-ds-ui-bg-inset); - border: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-subtle); + background: var(--ai-studio-log-detail-bg); + border: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-default); border-radius: var(--wb-ds-radius-100); font-family: var(--wb-public-font-family-mono, ui-monospace, 'Cascadia Mono', monospace); font-size: 0.625rem; diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index e4ec306c0..176cd8e0a 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -243,7 +243,7 @@ export function ExecutionLogPanel() { className={styles['collapse']} prefixIcon={} /> - Execution log + Execution log Date: Tue, 6 Oct 2026 10:37:09 +0200 Subject: [PATCH 4/7] fix(ai-studio): the execution log toggle and detail box follow the prototype --- .../components/execution/log-panel.module.css | 74 +++++++++++++++-- .../src/components/execution/log-panel.tsx | 82 ++++++++++++++++--- 2 files changed, 139 insertions(+), 17 deletions(-) diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index 2d5cc00ee..f4747aedd 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -19,10 +19,40 @@ overflow: hidden; } +/* Inside the dock rather than over its edge: the dock clips its overflow for the rounded corners. */ +.resize { + position: absolute; + inset: 0 0 auto; + z-index: 1; + height: var(--wb-ds-size-100); + cursor: ns-resize; + touch-action: none; + + &::after { + content: ''; + position: absolute; + inset: 0 var(--wb-ds-space-150) auto; + height: var(--wb-ds-size-25); + border-radius: var(--wb-ds-radius-full); + background: transparent; + transition: background 0.1s; + } + + &:hover::after, + &:active::after, + &:focus-visible::after { + background: var(--wb-ds-ui-brand-border); + } + + &:focus-visible { + outline: none; + } +} + .header { display: flex; align-items: center; - gap: var(--wb-ds-space-100); + gap: var(--wb-ds-space-150); min-height: 2.625rem; padding: var(--wb-ds-space-75) var(--wb-ds-space-150) var(--wb-ds-space-75) var(--wb-ds-space-75); border-bottom: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-divider); @@ -35,15 +65,44 @@ border-bottom: none; } -.dock-title { +.toggle { + display: inline-flex; + align-items: center; + gap: var(--wb-ds-space-100); + padding: var(--wb-ds-space-62) var(--wb-ds-space-125) var(--wb-ds-space-62) var(--wb-ds-space-75); + border: none; + border-radius: var(--wb-ds-radius-75); + background: none; font-size: var(--wb-ds-font-size-162); + line-height: 1; + color: var(--wb-ds-ui-text-default); + cursor: pointer; + + &:hover { + background: var(--wb-ds-ui-bg-inset-subtle); + } + + &:focus-visible { + outline: var(--wb-ds-size-25) solid var(--wb-ds-ui-stroke-focus); + outline-offset: var(--wb-ds-size-25); + } } -.collapse svg { +.caret { + display: grid; + place-items: center; + width: var(--wb-ds-size-200); + height: var(--wb-ds-size-200); + color: var(--wb-ds-ui-text-muted-default); transition: transform 0.15s; + + svg { + width: 0.875rem; + height: 0.875rem; + } } -.dock--collapsed .collapse svg { +.dock--collapsed .caret { transform: rotate(180deg); } @@ -82,7 +141,8 @@ } .body { - height: 12.5rem; + /* stylelint-disable-next-line csstools/value-no-unknown-custom-properties -- set from log-panel.tsx when resized */ + height: var(--log-body-height, 12.5rem); overflow-y: auto; padding: var(--wb-ds-space-75) var(--wb-ds-space-100) var(--wb-ds-space-100); } @@ -98,7 +158,7 @@ line-height: 1.45; &:hover { - background: var(--wb-ds-ui-bg-fill-hover); + background: var(--wb-ds-ui-bg-inset-subtle); } } @@ -144,7 +204,7 @@ .detail { grid-column: 3; - margin-top: var(--wb-ds-space-25); + margin-top: var(--wb-ds-space-62); padding: var(--wb-ds-space-75) var(--wb-ds-space-112); background: var(--ai-studio-log-detail-bg); border: var(--wb-ds-size-12) solid var(--wb-ds-ui-stroke-default); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 176cd8e0a..655185e53 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -1,5 +1,6 @@ +import { CaretDown } from '@phosphor-icons/react'; import { Icon, useSingleSelectedElement, useStore } from '@workflowbuilder/sdk'; -import { Chip, NavButton } from '@workflowbuilder/ui'; +import { Chip } from '@workflowbuilder/ui'; import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; @@ -88,6 +89,14 @@ const RUN_STATUS_LOOK: Record(null); const stickToBottomRef = useRef(true); + // `undefined` keeps the default height from the stylesheet; double-click or Home goes back to it. + const [bodyHeight, setBodyHeight] = useState(); + const resizeStartRef = useRef<{ pointerY: number; height: number } | undefined>(undefined); useEffect(() => { stickToBottomRef.current = true; @@ -211,6 +223,37 @@ export function ExecutionLogPanel() { stickToBottomRef.current = distanceFromBottom < AT_BOTTOM_TOLERANCE_PX; } + function handleResizeStart(event: React.PointerEvent) { + const height = bodyRef.current?.getBoundingClientRect().height; + if (height === undefined) return; + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeStartRef.current = { pointerY: event.clientY, height }; + } + + function handleResizeMove(event: React.PointerEvent) { + const start = resizeStartRef.current; + if (!start) return; + + setBodyHeight(clampBodyHeight(start.height + start.pointerY - event.clientY)); + } + + function handleResizeEnd(event: React.PointerEvent) { + resizeStartRef.current = undefined; + event.currentTarget.releasePointerCapture(event.pointerId); + } + + function handleResizeKey(event: React.KeyboardEvent) { + const current = bodyRef.current?.getBoundingClientRect().height ?? 0; + if (event.key === 'ArrowUp') setBodyHeight(clampBodyHeight(current + RESIZE_KEY_STEP_PX)); + else if (event.key === 'ArrowDown') setBodyHeight(clampBodyHeight(current - RESIZE_KEY_STEP_PX)); + else if (event.key === 'Home') setBodyHeight(undefined); + else return; + + event.preventDefault(); + } + function handleHeaderClick({ target }: React.MouseEvent) { if (target instanceof Element && target.closest('button')) return; toggleLog(); @@ -229,21 +272,40 @@ export function ExecutionLogPanel() { { '--log-dock-left': `${leftOffset}px`, '--log-dock-right': `${rightOffset}px`, + '--log-body-height': bodyHeight === undefined ? undefined : `${bodyHeight}px`, } as React.CSSProperties } > + {!isCollapsed && ( +
setBodyHeight(undefined)} + onKeyDown={handleResizeKey} + /> + )}
- } - /> - Execution log + > + + + + Execution log + Date: Tue, 6 Oct 2026 11:01:08 +0200 Subject: [PATCH 5/7] feat(ai-studio): the execution log resizes by dragging its top edge --- .../components/execution/log-panel.test.tsx | 16 ++++++++++++++ .../src/components/execution/log-panel.tsx | 21 +++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/ai-studio/src/components/execution/log-panel.test.tsx b/apps/ai-studio/src/components/execution/log-panel.test.tsx index 2b38f2498..36f5e0ec2 100644 --- a/apps/ai-studio/src/components/execution/log-panel.test.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.test.tsx @@ -166,4 +166,20 @@ describe('ExecutionLogPanel', () => { expect(row.querySelector('[class*="detail"]')?.textContent).toBe('boom'); expect(row.textContent).not.toContain('Failed — boom'); }); + + it('resizes the log from the keyboard and goes back to the default height on Home', () => { + renderAfter(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })); + const handle = container.querySelector('[role="separator"]') as HTMLElement; + const dock = container.querySelector('section') as HTMLElement; + const press = (key: string) => + act(() => { + handle.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + }); + + press('ArrowUp'); + expect(dock.style.getPropertyValue('--log-body-height')).toBe('120px'); + + press('Home'); + expect(dock.style.getPropertyValue('--log-body-height')).toBe(''); + }); }); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 655185e53..529610395 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -246,10 +246,23 @@ export function ExecutionLogPanel() { function handleResizeKey(event: React.KeyboardEvent) { const current = bodyRef.current?.getBoundingClientRect().height ?? 0; - if (event.key === 'ArrowUp') setBodyHeight(clampBodyHeight(current + RESIZE_KEY_STEP_PX)); - else if (event.key === 'ArrowDown') setBodyHeight(clampBodyHeight(current - RESIZE_KEY_STEP_PX)); - else if (event.key === 'Home') setBodyHeight(undefined); - else return; + switch (event.key) { + case 'ArrowUp': { + setBodyHeight(clampBodyHeight(current + RESIZE_KEY_STEP_PX)); + break; + } + case 'ArrowDown': { + setBodyHeight(clampBodyHeight(current - RESIZE_KEY_STEP_PX)); + break; + } + case 'Home': { + setBodyHeight(undefined); + break; + } + default: { + return; + } + } event.preventDefault(); } From a282c1afddfa9ccb085aa3628e058c4876a9ce9f Mon Sep 17 00:00:00 2001 From: Dominika Pacholec Date: Wed, 7 Oct 2026 11:08:39 +0200 Subject: [PATCH 6/7] fix(ai-studio): the execution log stays on screen and reports its height --- .../src/components/execution/log-panel.module.css | 3 ++- .../src/components/execution/log-panel.test.tsx | 9 +++++++++ apps/ai-studio/src/components/execution/log-panel.tsx | 5 ++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index f4747aedd..ba7e13bec 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -141,8 +141,9 @@ } .body { + /* The bounds of clampBodyHeight in log-panel.tsx, repeated so a shorter window still applies them. */ /* stylelint-disable-next-line csstools/value-no-unknown-custom-properties -- set from log-panel.tsx when resized */ - height: var(--log-body-height, 12.5rem); + height: clamp(7.5rem, var(--log-body-height, 12.5rem), 60vh); overflow-y: auto; padding: var(--wb-ds-space-75) var(--wb-ds-space-100) var(--wb-ds-space-100); } diff --git a/apps/ai-studio/src/components/execution/log-panel.test.tsx b/apps/ai-studio/src/components/execution/log-panel.test.tsx index 36f5e0ec2..8bf51a881 100644 --- a/apps/ai-studio/src/components/execution/log-panel.test.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.test.tsx @@ -182,4 +182,13 @@ describe('ExecutionLogPanel', () => { press('Home'); expect(dock.style.getPropertyValue('--log-body-height')).toBe(''); }); + + it('reports the default height and both bounds on the resize handle before any resize', () => { + renderAfter(event({ type: 'execution_started', payload: { workflowId: 'wf-1' } })); + const handle = container.querySelector('[role="separator"]') as HTMLElement; + + expect(handle.getAttribute('aria-valuenow')).toBe('200'); + expect(handle.getAttribute('aria-valuemin')).toBe('120'); + expect(handle.getAttribute('aria-valuemax')).toBe(String(Math.round(window.innerHeight * 0.6))); + }); }); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 529610395..5a3edcce0 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -90,6 +90,8 @@ const RUN_STATUS_LOOK: Record Date: Wed, 7 Oct 2026 13:17:27 +0200 Subject: [PATCH 7/7] fix(ai-studio): the log detail returns to the box under the message --- .../src/components/execution/log-panel.module.css | 14 ++++++++------ .../src/components/execution/log-panel.test.tsx | 11 ++++++----- .../src/components/execution/log-panel.tsx | 11 +++-------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/ai-studio/src/components/execution/log-panel.module.css b/apps/ai-studio/src/components/execution/log-panel.module.css index ba7e13bec..478918bc2 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -197,12 +197,6 @@ white-space: pre-line; } -.message--clipped { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .detail { grid-column: 3; margin-top: var(--wb-ds-space-62); @@ -219,6 +213,14 @@ overflow-y: auto; } +.detail--clamped { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; +} + .row--highlighted, .row--highlighted:hover { background: var(--wb-ds-ui-bg-selected); diff --git a/apps/ai-studio/src/components/execution/log-panel.test.tsx b/apps/ai-studio/src/components/execution/log-panel.test.tsx index 8bf51a881..41fd46a11 100644 --- a/apps/ai-studio/src/components/execution/log-panel.test.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.test.tsx @@ -154,17 +154,18 @@ describe('ExecutionLogPanel', () => { expect(container.querySelector('[class*="header"]')?.textContent).toContain('Incomplete'); }); - it('keeps the detail on the message line until the row is expanded', () => { + it('clamps the detail box under the message until the row is expanded', () => { renderAfter(event({ type: 'node_failed', nodeId: 'classify-1', payload: { error: { message: 'boom' } } })); const row = nodeRow('classify-1') as HTMLElement; + const detail = () => row.querySelector('[class*="detail"]'); - expect(row.textContent).toContain('Failed — boom'); - expect(row.querySelector('[class*="detail"]')).toBeNull(); + expect(row.textContent).not.toContain('Failed — boom'); + expect(detail()?.textContent).toBe('boom'); + expect(detail()?.className).toContain('detail--clamped'); act(() => row.click()); - expect(row.querySelector('[class*="detail"]')?.textContent).toBe('boom'); - expect(row.textContent).not.toContain('Failed — boom'); + expect(detail()?.className).not.toContain('detail--clamped'); }); it('resizes the log from the keyboard and goes back to the default height on Home', () => { diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 5a3edcce0..01c2a349e 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -143,9 +143,6 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo const detail = eventDetail(event); const hasDetail = !!detail; - // Collapsed, the detail rides on the message line, clipped; expanded, it opens in full below. - const preview = hasDetail && !isExpanded ? detail : undefined; - const line = [subtitle, preview].filter(Boolean).join(' — '); function handleToggle({ target }: React.MouseEvent) { const clickedInteractiveElement = target instanceof Element && !!target.closest('a, button'); @@ -169,12 +166,10 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo {formatTime(event.timestamp)} {title} - {line && ( - - {line} - + {subtitle && {subtitle}} + {hasDetail && ( +
{detail}
)} - {isExpanded &&
{detail}
}
); }