Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { useState } from 'react';

import styles from './disclaimer-modal.module.css';

import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor';
import { useExecutionStore } from '../../stores/use-execution-store';

const STORAGE_KEY = 'ai-studio:disclaimer-acknowledged';

function hasAcknowledged(): boolean {
Expand All @@ -14,20 +17,24 @@ function hasAcknowledged(): boolean {
}

export function DisclaimerModal() {
const [open, setOpen] = useState(() => !hasAcknowledged());
const [isOpen, setIsOpen] = useState(() => !hasAcknowledged());
const isLogVisible = useExecutionStore((state) => state.events.length > 0 || state.status !== 'idle');
const { isPanelExpanded } = useRightPanelAnchor();

function dismiss() {
try {
localStorage.setItem(STORAGE_KEY, 'true');
} catch {
// storage unavailable
}
setOpen(false);
setIsOpen(false);
}

if (!open) {
if (!isOpen) {
if (isLogVisible || isPanelExpanded) return null;

return (
<button className={styles['reopen']} onClick={() => setOpen(true)} aria-label="About this demo">
<button className={styles['reopen']} onClick={() => setIsOpen(true)} aria-label="About this demo">
<Info size={20} weight="bold" />
</button>
);
Expand Down
17 changes: 7 additions & 10 deletions apps/ai-studio/src/components/execution/log-panel.module.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
.panel {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
bottom: 1rem;
right: var(--log-panel-right, 1rem);
width: 30rem;
max-width: calc(100vw - 3rem);
max-height: 60vh;
max-height: 26.875rem;
background: var(--wb-app-bar-background);
border: 0.0625rem solid var(--wb-app-bar-border-color);
border-radius: var(--wb-app-bar-border-radius);
Expand Down Expand Up @@ -63,15 +62,14 @@
}
}

.event--toggleable {
cursor: pointer;
}

.event-header {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: default;
}

.event-header:has(+ .detail) {
cursor: pointer;
}

.badge {
Expand Down Expand Up @@ -126,7 +124,6 @@
word-break: break-word;
line-height: 1.5;
font-size: 0.7rem;
cursor: pointer;
}

.detail--expanded {
Expand Down
86 changes: 62 additions & 24 deletions apps/ai-studio/src/components/execution/log-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import { useSingleSelectedElement } from '@workflowbuilder/sdk';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';

import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';

import styles from './log-panel.module.css';

import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor';
import { toggleLog, useExecutionStore } from '../../stores/use-execution-store';
import { extractOutputText } from '../../utils/extract-output-text';

function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
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 [expanded, setExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);

const nodeId = (event as { nodeId?: string | null }).nodeId;
const isNode = typeof nodeId === 'string' && nodeId.length > 0;
const highlighted = isNode && nodeId === selectedNodeId;
const isHighlighted = isNode && nodeId === selectedNodeId;
const label = event.type.replaceAll('_', ' ');

let detail: string | undefined;
Expand All @@ -41,60 +47,92 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo
}

const hasDetail = !!detail;
const truncated = detail && detail.length > 120 ? detail.slice(0, 120) + '…' : 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');
const isSelectingText = !!globalThis.getSelection()?.toString();

if (hasDetail && !clickedInteractiveElement && !isSelectingText) {
setIsExpanded((current) => !current);
}
}

return (
<div
data-node-id={isNode ? nodeId : undefined}
className={`${styles['event']} ${styles[`event--${event.type.split('_')[0]}`] ?? ''} ${highlighted ? styles['event--highlighted'] : ''}`}
className={clsx(styles['event'], {
[styles['event--toggleable']]: hasDetail,
[styles['event--highlighted']]: isHighlighted,
})}
onClick={handleToggle}
>
<div className={styles['event-header']} onClick={() => hasDetail && setExpanded((v) => !v)}>
<span className={`${styles['badge']} ${styles[`badge--${event.type}`]}`}>{label}</span>
{isNode && <span className={styles['node-id']}>{(event as { nodeId: string }).nodeId.slice(0, 8)}</span>}
<div className={styles['event-header']}>
<span className={clsx(styles['badge'], styles[`badge--${event.type}`])}>{label}</span>
{isNode && <span className={styles['node-id']}>{nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}</span>}
<span className={styles['time']}>{formatTime(event.timestamp)}</span>
{hasDetail && <span className={styles['toggle']}>{expanded ? '▲' : '▼'}</span>}
{hasDetail && <span className={styles['toggle']}>{isExpanded ? '▲' : '▼'}</span>}
</div>
{hasDetail && (
<div className={`${styles['detail']} ${expanded ? styles['detail--expanded'] : ''}`}>
{expanded ? detail : truncated}
<div className={clsx(styles['detail'], { [styles['detail--expanded']]: isExpanded })}>
{isExpanded ? detail : truncated}
</div>
)}
</div>
);
}

export function ExecutionLogPanel() {
const events = useExecutionStore((s) => s.events);
const status = useExecutionStore((s) => s.status);
const collapsed = useExecutionStore((s) => s.logCollapsed);
const events = useExecutionStore((state) => state.events);
const status = useExecutionStore((state) => state.status);
const executionId = useExecutionStore((state) => state.executionId);
const isCollapsed = useExecutionStore((state) => state.isLogCollapsed);
// 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 { rightOffset } = useRightPanelAnchor();

const bodyRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true);

useEffect(() => {
stickToBottomRef.current = true;
}, [executionId]);

useEffect(() => {
if (!collapsed && bodyRef.current) {
if (!isCollapsed && stickToBottomRef.current && bodyRef.current) {
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}
}, [events.length, collapsed]);
}, [events.length, isCollapsed]);

useEffect(() => {
if (!selectedNodeId || collapsed) return;
if (!selectedNodeId || isCollapsed) return;
bodyRef.current?.querySelector(`[data-node-id="${selectedNodeId}"]`)?.scrollIntoView({ block: 'nearest' });
}, [selectedNodeId, collapsed]);
}, [selectedNodeId, isCollapsed]);

function handleBodyScroll() {
const body = bodyRef.current;
if (!body) return;

const distanceFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight;
stickToBottomRef.current = distanceFromBottom < AT_BOTTOM_TOLERANCE_PX;
}

if (events.length === 0 && status === 'idle') return null;

return (
<div className={`${styles['panel']} ${collapsed ? styles['panel--collapsed'] : ''}`}>
<div
className={clsx(styles['panel'], { [styles['panel--collapsed']]: isCollapsed })}
style={{ '--log-panel-right': `${rightOffset}px` } as React.CSSProperties}
>
<div className={styles['header']} onClick={toggleLog}>
<span className={styles['title']}>Execution Log</span>
<span className={`${styles['status']} ${styles[`status--${status}`]}`}>{status}</span>
<span className={styles['toggle']}>{collapsed ? '▲' : '▼'}</span>
<span className={clsx(styles['status'], styles[`status--${status}`])}>{status}</span>
<span className={styles['toggle']}>{isCollapsed ? '▲' : '▼'}</span>
</div>
{!collapsed && (
<div ref={bodyRef} className={styles['body']}>
{!isCollapsed && (
<div ref={bodyRef} className={styles['body']} onScroll={handleBodyScroll}>
{events.map((event) => (
<EventRow key={`${event.executionId}-${event.sequence}`} event={event} selectedNodeId={selectedNodeId} />
))}
Expand Down
64 changes: 64 additions & 0 deletions apps/ai-studio/src/hooks/use-right-panel-anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';

const GAP_PX = 16;
const EXPANDED_HEIGHT_RATIO = 0.9;

type RightPanelAnchor = {
isPanelExpanded: boolean;
rightOffset: number;
};

const collapsedAnchor: RightPanelAnchor = { isPanelExpanded: false, rightOffset: GAP_PX };

function findRightPanel() {
const panel = document.querySelector('#viewport-bounds')?.nextElementSibling;

return panel instanceof HTMLElement ? panel : undefined;
}

function measureAnchor(panel: HTMLElement): RightPanelAnchor {
const sidebar = panel.firstElementChild?.firstElementChild;
if (!(sidebar instanceof HTMLElement)) return collapsedAnchor;

const isPanelExpanded = sidebar.offsetHeight >= panel.offsetHeight * EXPANDED_HEIGHT_RATIO;
if (!isPanelExpanded) return collapsedAnchor;

return {
isPanelExpanded: true,
rightOffset: Math.round(window.innerWidth - sidebar.getBoundingClientRect().left) + GAP_PX,
};
}

function sameAnchor(current: RightPanelAnchor, next: RightPanelAnchor) {
return current.isPanelExpanded === next.isPanelExpanded && current.rightOffset === next.rightOffset;
}

function observeAnchor(panel: HTMLElement, onMeasure: (next: RightPanelAnchor) => 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 SDK does not expose the properties panel's expanded state, so this
// measures the DOM: the panel is the element after #viewport-bounds.
export function useRightPanelAnchor(): RightPanelAnchor {
const [anchor, setAnchor] = useState(collapsedAnchor);

useEffect(() => {
const panel = findRightPanel();
if (!panel) return;

return observeAnchor(panel, (next) => setAnchor((current) => (sameAnchor(current, next) ? current : next)));
}, []);

return anchor;
}
24 changes: 16 additions & 8 deletions apps/ai-studio/src/stores/use-execution-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';

import type {
ExecutionEvent,
Expand All @@ -21,7 +21,7 @@ type ExecutionStore = {
streamUrl: string | undefined;
nodeStates: Record<string, NodeExecutionState>;
events: ExecutionEvent[];
logCollapsed: boolean;
isLogCollapsed: boolean;
};

const emptyStore: ExecutionStore = {
Expand All @@ -30,15 +30,22 @@ const emptyStore: ExecutionStore = {
streamUrl: undefined,
nodeStates: {},
events: [],
logCollapsed: false,
isLogCollapsed: false,
};

export const useExecutionStore = create<ExecutionStore>()(
devtools(() => ({ ...emptyStore }), { name: 'aiStudioExecutionStore' }),
devtools(
persist(() => ({ ...emptyStore }), {
name: 'ai-studio:execution-log',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({ isLogCollapsed: state.isLogCollapsed }),
}),
{ name: 'aiStudioExecutionStore' },
),
);

export function resetExecution() {
useExecutionStore.setState(emptyStore);
useExecutionStore.setState((state) => ({ ...emptyStore, isLogCollapsed: state.isLogCollapsed }));
}

export function setExecutionStarted(executionId: string, streamUrl: string) {
Expand All @@ -48,6 +55,7 @@ export function setExecutionStarted(executionId: string, streamUrl: string) {
streamUrl,
nodeStates: {},
events: [],
isLogCollapsed: false,
});
}

Expand Down Expand Up @@ -102,12 +110,12 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record<string, No
}
}

export function setLogCollapsed(logCollapsed: boolean) {
useExecutionStore.setState({ logCollapsed });
export function setLogCollapsed(isLogCollapsed: boolean) {
useExecutionStore.setState({ isLogCollapsed });
}

export function toggleLog() {
useExecutionStore.setState((state) => ({ logCollapsed: !state.logCollapsed }));
setLogCollapsed(!useExecutionStore.getState().isLogCollapsed);
}

function eventToExecutionStatus(event: ExecutionEvent): ExecutionStatus | undefined {
Expand Down
Loading