diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx index eaa5138e0..01961e551 100644 --- a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx +++ b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx @@ -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 { @@ -14,7 +17,9 @@ 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 { @@ -22,12 +27,14 @@ export function DisclaimerModal() { } catch { // storage unavailable } - setOpen(false); + setIsOpen(false); } - if (!open) { + if (!isOpen) { + if (isLogVisible || isPanelExpanded) return null; + return ( - ); 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 20627114d..ec687cd99 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -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); @@ -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 { @@ -126,7 +124,6 @@ word-break: break-word; line-height: 1.5; font-size: 0.7rem; - cursor: pointer; } .detail--expanded { diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index f3781c840..31bf79880 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -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; @@ -41,22 +47,36 @@ 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 (
-
hasDetail && setExpanded((v) => !v)}> - {label} - {isNode && {(event as { nodeId: string }).nodeId.slice(0, 8)}} +
+ {label} + {isNode && {nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}} {formatTime(event.timestamp)} - {hasDetail && {expanded ? '▲' : '▼'}} + {hasDetail && {isExpanded ? '▲' : '▼'}}
{hasDetail && ( -
- {expanded ? detail : truncated} +
+ {isExpanded ? detail : truncated}
)}
@@ -64,37 +84,55 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo } 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(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 ( -
+
Execution Log - {status} - {collapsed ? '▲' : '▼'} + {status} + {isCollapsed ? '▲' : '▼'}
- {!collapsed && ( -
+ {!isCollapsed && ( +
{events.map((event) => ( ))} diff --git a/apps/ai-studio/src/hooks/use-right-panel-anchor.ts b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts new file mode 100644 index 000000000..ac1227109 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts @@ -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; +} diff --git a/apps/ai-studio/src/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts index 563c58373..effb405b6 100644 --- a/apps/ai-studio/src/stores/use-execution-store.ts +++ b/apps/ai-studio/src/stores/use-execution-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; +import { createJSONStorage, devtools, persist } from 'zustand/middleware'; import type { ExecutionEvent, @@ -21,7 +21,7 @@ type ExecutionStore = { streamUrl: string | undefined; nodeStates: Record; events: ExecutionEvent[]; - logCollapsed: boolean; + isLogCollapsed: boolean; }; const emptyStore: ExecutionStore = { @@ -30,15 +30,22 @@ const emptyStore: ExecutionStore = { streamUrl: undefined, nodeStates: {}, events: [], - logCollapsed: false, + isLogCollapsed: false, }; export const useExecutionStore = create()( - 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) { @@ -48,6 +55,7 @@ export function setExecutionStarted(executionId: string, streamUrl: string) { streamUrl, nodeStates: {}, events: [], + isLogCollapsed: false, }); } @@ -102,12 +110,12 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record ({ logCollapsed: !state.logCollapsed })); + setLogCollapsed(!useExecutionStore.getState().isLogCollapsed); } function eventToExecutionStatus(event: ExecutionEvent): ExecutionStatus | undefined {