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..f2463127b 100644 --- a/apps/ai-studio/src/components/execution/highlighting.css +++ b/apps/ai-studio/src/components/execution/highlighting.css @@ -1,11 +1,23 @@ 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); + /* missing token */ + --ai-studio-log-detail-bg: var(--wb-ds-colors-gray-200); } 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); + /* missing token */ + --ai-studio-log-detail-bg: var(--wb-ds-colors-gray-800); } :root { @@ -15,6 +27,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..478918bc2 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -1,196 +1,267 @@ -.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; +/* 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: 0.5rem; - padding: 0.5rem 0.75rem; + 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); 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; -} - .toggle { - font-size: 0.6rem; - opacity: 0.5; -} + 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; -.body { - overflow-y: auto; - flex: 1; - padding: 0.25rem 0; + &: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); + } } -.event { - padding: 0.375rem 0.75rem; - border-bottom: 0.0625rem solid color-mix(in srgb, var(--wb-sdk-app-bar-border-color), transparent 50%); +.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; - &:last-child { - border-bottom: none; + svg { + width: 0.875rem; + height: 0.875rem; } } -.event--toggleable { - cursor: pointer; +.dock--collapsed .caret { + transform: rotate(180deg); } -.event-header { - display: flex; - align-items: center; - gap: 0.375rem; +.chip { + color: var(--ai-studio-status-color--tone); + background: color-mix(in srgb, var(--ai-studio-status-color--tone), transparent 88%); } -.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--running { + --ai-studio-status-color--tone: var(--ai-studio-status-color--running); } -.badge--execution_started, -.badge--execution_completed, -.badge--node_completed { - color: var(--ai-studio-status-color--completed); +.chip--completed { + --ai-studio-status-color--tone: var(--ai-studio-status-color--completed); } -.badge--node_started, -.badge--node_skipped { - color: var(--wb-ds-ui-text-subtle-default); +.chip--waiting { + --ai-studio-status-color--tone: var(--ai-studio-status-color--human); } -.badge--node_waiting { - color: var(--ai-studio-status-color--waiting); +.chip--failed { + --ai-studio-status-color--tone: var(--ai-studio-status-color--failed); } -.badge--execution_incomplete { - color: var(--ai-studio-status-color--incomplete); +.chip--warning, +.chip--incomplete { + --ai-studio-status-color--tone: var(--ai-studio-status-color--incomplete); } -.badge--node_failed, -.badge--execution_failed { - color: var(--ai-studio-status-color--failed); +.chip--branch, +.chip--join, +.chip--skipped, +.chip--info, +.chip--neutral { + --ai-studio-status-color--tone: var(--wb-ds-ui-text-subtle-default); } -.badge--execution_cancelled { - color: var(--wb-ds-ui-text-subtle-default); +.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: 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); } -.node-id { - font-family: monospace; - font-size: 0.65rem; - opacity: 0.5; +.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-inset-subtle); + } } -.reason { - font-size: 0.65rem; - font-style: italic; - opacity: 0.55; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.row--toggleable { + cursor: pointer; } .time { - margin-left: auto; - font-size: 0.65rem; - opacity: 0.45; - white-space: nowrap; + 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; } -.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; - white-space: pre-wrap; - word-break: break-word; - line-height: 1.5; - font-size: 0.7rem; +.status { + margin-top: 0.0625rem; } -.detail--expanded { - max-height: 20rem; - overflow-y: auto; +.title, +.message { + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.45; } -.status { - font-size: 0.65rem; +.title { font-weight: 500; - padding: 0.1rem 0.35rem; - border-radius: 0.25rem; } -.status--idle { - opacity: 0.4; +.message { + grid-column: 3; + font-size: var(--wb-ds-font-size-137); + color: var(--wb-ds-ui-text-subtle-default); + white-space: pre-line; } -.status--pending, -.status--running, -.status--completed { - color: var(--ai-studio-status-color--completed); - background: var(--ai-studio-status-bg--completed); +.detail { + grid-column: 3; + 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); + 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; + max-height: 20rem; + overflow-y: auto; } -.status--waiting { - color: var(--ai-studio-status-color--waiting); - background: var(--ai-studio-status-bg--waiting); +.detail--clamped { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; } -.status--incomplete, -.status--disconnected { - color: var(--ai-studio-status-color--incomplete); - background: var(--ai-studio-status-bg--incomplete); +.row--highlighted, +.row--highlighted:hover { + background: var(--wb-ds-ui-bg-selected); } -.status--failed { - color: var(--ai-studio-status-color--failed); +.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); + + .title { + color: var(--ai-studio-status-color--failed); + font-weight: 700; + } } -.status--cancelled, -.status--cancelling { - opacity: 0.5; +.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); } -.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); +/* 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); + } + + .title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .message { + grid-column: 4; + grid-row: 1; + } + + .detail { + 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 51fefd93f..41fd46a11 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,111 @@ 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'); + }); + + 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).not.toContain('Failed — boom'); + expect(detail()?.textContent).toBe('boom'); + expect(detail()?.className).toContain('detail--clamped'); + + act(() => row.click()); + + expect(detail()?.className).not.toContain('detail--clamped'); + }); + + 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(''); + }); + + 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 a6771f197..01c2a349e 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -1,4 +1,6 @@ -import { useSingleSelectedElement } from '@workflowbuilder/sdk'; +import { CaretDown } from '@phosphor-icons/react'; +import { Icon, useSingleSelectedElement, useStore } from '@workflowbuilder/sdk'; +import { Chip } from '@workflowbuilder/ui'; import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; @@ -6,9 +8,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,59 +20,129 @@ const SKIP_REASON_LABEL: Record = { error_route_not_taken: 'error branch not taken', }; -const DETAIL_PREVIEW_CHARS = 120; -const NODE_ID_PREVIEW_CHARS = 8; +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 AT_BOTTOM_TOLERANCE_PX = 4; +const MIN_BODY_HEIGHT_PX = 120; +// The stylesheet's default `12.5rem`: change the two together. +const DEFAULT_BODY_HEIGHT_PX = 200; +const MAX_BODY_HEIGHT_RATIO = 0.6; +const RESIZE_KEY_STEP_PX = 16; + +function clampBodyHeight(height: number) { + const max = Math.max(MIN_BODY_HEIGHT_PX, window.innerHeight * MAX_BODY_HEIGHT_RATIO); + return Math.round(Math.min(Math.max(height, MIN_BODY_HEIGHT_PX), max)); +} 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; function handleToggle({ target }: React.MouseEvent) { const clickedInteractiveElement = target instanceof Element && !!target.closest('a, button'); @@ -82,23 +156,19 @@ 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} -
+
{detail}
)}
); @@ -112,10 +182,14 @@ 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); 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; @@ -129,7 +203,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 +220,109 @@ 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; + 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(); + } + + 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 ? '▲' : '▼'} + {!isCollapsed && ( +
setBodyHeight(undefined)} + onKeyDown={handleResizeKey} + /> + )} +
+ + : undefined} + className={clsx(styles['chip'], styles[`chip--${runStatus.tone}`])} + />
{!isCollapsed && (
@@ -159,6 +331,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 = {