From 5e3a855ec99c378ca5edcd6a66cdbb03996d9711 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 10 Jul 2026 15:52:08 +0200 Subject: [PATCH 01/10] feat(ai-studio): dock execution log beside the properties panel The log is no longer a centered overlay covering the canvas. It floats bottom-right: against the left edge of the properties panel when the panel is expanded, at the viewport edge otherwise. - height grows with entries up to 430px instead of a fixed 60vh - auto-scroll follows the newest entry; manual scroll pauses it, returning to the bottom resumes it - collapse state persists in sessionStorage for the session - starting an execution reopens a collapsed log --- .../components/execution/log-panel.module.css | 8 +-- .../src/components/execution/log-panel.tsx | 25 +++++++- .../src/hooks/use-right-panel-anchor.ts | 60 +++++++++++++++++++ .../src/stores/use-execution-store.ts | 33 +++++++++- 4 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 apps/ai-studio/src/hooks/use-right-panel-anchor.ts 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..31d79a963 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,11 @@ .panel { position: fixed; - bottom: 1.5rem; - left: 50%; - transform: translateX(-50%); + /* Aligned with the bottom edge of the SDK right panel (1rem layout padding). */ + bottom: 1rem; + right: var(--log-panel-right, 1rem); width: 30rem; max-width: calc(100vw - 3rem); - max-height: 60vh; + max-height: 26.875rem; /* 430px */ background: var(--wb-app-bar-background); border: 0.0625rem solid var(--wb-app-bar-border-color); border-radius: var(--wb-app-bar-border-radius); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index f3781c840..587fa1dbc 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -5,6 +5,7 @@ import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/ 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'; @@ -66,15 +67,24 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo export function ExecutionLogPanel() { const events = useExecutionStore((s) => s.events); const status = useExecutionStore((s) => s.status); + const executionId = useExecutionStore((s) => s.executionId); const collapsed = useExecutionStore((s) => s.logCollapsed); // 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); + // Follow the newest entry only while the user stays at the bottom; any + // scroll away pauses following, scrolling back to the bottom resumes it. + const stickToBottomRef = useRef(true); useEffect(() => { - if (!collapsed && bodyRef.current) { + stickToBottomRef.current = true; + }, [executionId]); + + useEffect(() => { + if (!collapsed && stickToBottomRef.current && bodyRef.current) { bodyRef.current.scrollTop = bodyRef.current.scrollHeight; } }, [events.length, collapsed]); @@ -84,17 +94,26 @@ export function ExecutionLogPanel() { bodyRef.current?.querySelector(`[data-node-id="${selectedNodeId}"]`)?.scrollIntoView({ block: 'nearest' }); }, [selectedNodeId, collapsed]); + function handleBodyScroll() { + const body = bodyRef.current; + if (!body) return; + stickToBottomRef.current = body.scrollHeight - body.scrollTop - body.clientHeight < 4; + } + if (events.length === 0 && status === 'idle') return null; return ( -
+
Execution Log {status} {collapsed ? '▲' : '▼'}
{!collapsed && ( -
+
{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..1bdc655b7 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; + +const GAP_PX = 16; + +type RightPanelAnchor = { + /** Whether the SDK properties panel is expanded to full content height. */ + panelExpanded: boolean; + /** + * Distance (px) from the right viewport edge at which a floating element + * should sit so it hugs the properties panel: left of the panel when it + * is expanded, at the viewport edge when it is collapsed or absent. + */ + rightOffset: number; +}; + +const collapsedAnchor: RightPanelAnchor = { panelExpanded: false, rightOffset: GAP_PX }; + +/** + * The SDK does not expose the properties panel's expanded state, so this + * measures the DOM: the right panel is the element after `#viewport-bounds` + * in the default layout, and it is expanded when it fills the content height. + */ +export function useRightPanelAnchor(): RightPanelAnchor { + const [anchor, setAnchor] = useState(collapsedAnchor); + + useEffect(() => { + const anchorElement = document.querySelector('#viewport-bounds')?.nextElementSibling; + if (!(anchorElement instanceof HTMLElement)) return; + const panel: HTMLElement = anchorElement; + + function measure() { + const sidebar = panel.firstElementChild?.firstElementChild; + const panelExpanded = sidebar instanceof HTMLElement && sidebar.offsetHeight >= panel.offsetHeight * 0.9; + + const next = panelExpanded + ? { + panelExpanded, + rightOffset: Math.round(window.innerWidth - sidebar.getBoundingClientRect().left) + GAP_PX, + } + : collapsedAnchor; + + setAnchor((current) => + current.panelExpanded === next.panelExpanded && current.rightOffset === next.rightOffset ? current : next, + ); + } + + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(panel); + window.addEventListener('resize', measure); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + 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..18fad0622 100644 --- a/apps/ai-studio/src/stores/use-execution-store.ts +++ b/apps/ai-studio/src/stores/use-execution-store.ts @@ -24,13 +24,31 @@ type ExecutionStore = { logCollapsed: boolean; }; +const LOG_COLLAPSED_STORAGE_KEY = 'ai-studio:log-collapsed'; + +function readStoredLogCollapsed() { + try { + return sessionStorage.getItem(LOG_COLLAPSED_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function storeLogCollapsed(logCollapsed: boolean) { + try { + sessionStorage.setItem(LOG_COLLAPSED_STORAGE_KEY, String(logCollapsed)); + } catch { + // Storage unavailable (e.g. blocked); collapse state just won't persist. + } +} + const emptyStore: ExecutionStore = { executionId: undefined, status: 'idle', streamUrl: undefined, nodeStates: {}, events: [], - logCollapsed: false, + logCollapsed: readStoredLogCollapsed(), }; export const useExecutionStore = create()( @@ -38,16 +56,19 @@ export const useExecutionStore = create()( ); export function resetExecution() { - useExecutionStore.setState(emptyStore); + useExecutionStore.setState({ ...emptyStore, logCollapsed: readStoredLogCollapsed() }); } export function setExecutionStarted(executionId: string, streamUrl: string) { + // Opening the log on every run is an event-driven override, deliberately + // not written to sessionStorage - the stored value tracks user toggles only. useExecutionStore.setState({ executionId, status: 'pending', streamUrl, nodeStates: {}, events: [], + logCollapsed: false, }); } @@ -103,11 +124,17 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record ({ logCollapsed: !state.logCollapsed })); + useExecutionStore.setState((state) => { + const logCollapsed = !state.logCollapsed; + storeLogCollapsed(logCollapsed); + + return { logCollapsed }; + }); } function eventToExecutionStatus(event: ExecutionEvent): ExecutionStatus | undefined { From dc5dd6191842f9867f5463a133611020eb13190d Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 10 Jul 2026 15:53:03 +0200 Subject: [PATCH 02/10] fix(ai-studio): make the whole log entry a collapse toggle The gray detail box showed a pointer cursor but ignored clicks; only the entry header toggled. Now the entire entry (header + detail) is one toggle area. - text selection inside the entry does not trigger the toggle, so log content stays copyable - clicks on interactive elements (links, buttons) inside the detail do not propagate to the toggle --- .../src/components/execution/log-panel.module.css | 10 ++++------ .../src/components/execution/log-panel.tsx | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 8 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 31d79a963..da3c8adce 100644 --- a/apps/ai-studio/src/components/execution/log-panel.module.css +++ b/apps/ai-studio/src/components/execution/log-panel.module.css @@ -63,15 +63,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 +125,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 587fa1dbc..89cd66bfb 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -44,12 +44,22 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo const hasDetail = !!detail; const truncated = detail && detail.length > 120 ? detail.slice(0, 120) + '…' : detail; + function handleToggle(mouseEvent: React.MouseEvent) { + if (!hasDetail) return; + // Selecting text (to copy log content) must not toggle the entry. + if (globalThis.getSelection()?.toString()) return; + // Nor should clicks on interactive elements inside the detail. + if (mouseEvent.target instanceof Element && mouseEvent.target.closest('a, button')) return; + setExpanded((v) => !v); + } + return (
-
hasDetail && setExpanded((v) => !v)}> +
{label} {isNode && {(event as { nodeId: string }).nodeId.slice(0, 8)}} {formatTime(event.timestamp)} From 4425e95b7e2624ad0a3b10a54fc3ba7b4d8450ca Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Fri, 10 Jul 2026 15:53:44 +0200 Subject: [PATCH 03/10] fix(ai-studio): hide the welcome reopen button when the corner is busy The floating info button shares the bottom-right corner with the execution log and overlapped the expanded properties panel footer. Hide it while the log is on screen or the properties panel is expanded; it returns once the corner is free. --- .../src/components/disclaimer/disclaimer-modal.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx b/apps/ai-studio/src/components/disclaimer/disclaimer-modal.tsx index eaa5138e0..556761d7f 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 { @@ -15,6 +18,10 @@ function hasAcknowledged(): boolean { export function DisclaimerModal() { const [open, setOpen] = useState(() => !hasAcknowledged()); + // The reopen button shares the bottom-right corner with the execution log + // and the expanded properties panel - hide it while either is on screen. + const logVisible = useExecutionStore((s) => s.events.length > 0 || s.status !== 'idle'); + const { panelExpanded } = useRightPanelAnchor(); function dismiss() { try { @@ -26,6 +33,8 @@ export function DisclaimerModal() { } if (!open) { + if (logVisible || panelExpanded) return null; + return ( ); diff --git a/apps/ai-studio/src/components/execution/log-panel.tsx b/apps/ai-studio/src/components/execution/log-panel.tsx index 8799091a7..31bf79880 100644 --- a/apps/ai-studio/src/components/execution/log-panel.tsx +++ b/apps/ai-studio/src/components/execution/log-panel.tsx @@ -19,11 +19,11 @@ function formatTime(isoTimestamp: string) { } 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; @@ -55,7 +55,7 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo const isSelectingText = !!globalThis.getSelection()?.toString(); if (hasDetail && !clickedInteractiveElement && !isSelectingText) { - setExpanded((isExpanded) => !isExpanded); + setIsExpanded((current) => !current); } } @@ -64,7 +64,7 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo data-node-id={isNode ? nodeId : undefined} className={clsx(styles['event'], { [styles['event--toggleable']]: hasDetail, - [styles['event--highlighted']]: highlighted, + [styles['event--highlighted']]: isHighlighted, })} onClick={handleToggle} > @@ -72,11 +72,11 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo {label} {isNode && {nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}} {formatTime(event.timestamp)} - {hasDetail && {expanded ? '▲' : '▼'}} + {hasDetail && {isExpanded ? '▲' : '▼'}}
{hasDetail && ( -
- {expanded ? detail : truncated} +
+ {isExpanded ? detail : truncated}
)}
@@ -87,7 +87,7 @@ export function ExecutionLogPanel() { const events = useExecutionStore((state) => state.events); const status = useExecutionStore((state) => state.status); const executionId = useExecutionStore((state) => state.executionId); - const collapsed = useExecutionStore((state) => state.logCollapsed); + 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; @@ -101,15 +101,15 @@ export function ExecutionLogPanel() { }, [executionId]); useEffect(() => { - if (!collapsed && stickToBottomRef.current && 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; @@ -123,15 +123,15 @@ export function ExecutionLogPanel() { return (
Execution Log {status} - {collapsed ? '▲' : '▼'} + {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 index 1a9f6e51a..ac1227109 100644 --- a/apps/ai-studio/src/hooks/use-right-panel-anchor.ts +++ b/apps/ai-studio/src/hooks/use-right-panel-anchor.ts @@ -4,11 +4,11 @@ const GAP_PX = 16; const EXPANDED_HEIGHT_RATIO = 0.9; type RightPanelAnchor = { - panelExpanded: boolean; + isPanelExpanded: boolean; rightOffset: number; }; -const collapsedAnchor: RightPanelAnchor = { panelExpanded: false, rightOffset: GAP_PX }; +const collapsedAnchor: RightPanelAnchor = { isPanelExpanded: false, rightOffset: GAP_PX }; function findRightPanel() { const panel = document.querySelector('#viewport-bounds')?.nextElementSibling; @@ -20,17 +20,17 @@ function measureAnchor(panel: HTMLElement): RightPanelAnchor { const sidebar = panel.firstElementChild?.firstElementChild; if (!(sidebar instanceof HTMLElement)) return collapsedAnchor; - const panelExpanded = sidebar.offsetHeight >= panel.offsetHeight * EXPANDED_HEIGHT_RATIO; - if (!panelExpanded) return collapsedAnchor; + const isPanelExpanded = sidebar.offsetHeight >= panel.offsetHeight * EXPANDED_HEIGHT_RATIO; + if (!isPanelExpanded) return collapsedAnchor; return { - panelExpanded: true, + isPanelExpanded: true, rightOffset: Math.round(window.innerWidth - sidebar.getBoundingClientRect().left) + GAP_PX, }; } function sameAnchor(current: RightPanelAnchor, next: RightPanelAnchor) { - return current.panelExpanded === next.panelExpanded && current.rightOffset === next.rightOffset; + return current.isPanelExpanded === next.isPanelExpanded && current.rightOffset === next.rightOffset; } function observeAnchor(panel: HTMLElement, onMeasure: (next: RightPanelAnchor) => void) { diff --git a/apps/ai-studio/src/stores/use-execution-store.ts b/apps/ai-studio/src/stores/use-execution-store.ts index 3c6552e81..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,53 +21,41 @@ type ExecutionStore = { streamUrl: string | undefined; nodeStates: Record; events: ExecutionEvent[]; - logCollapsed: boolean; + isLogCollapsed: boolean; }; -const LOG_COLLAPSED_STORAGE_KEY = 'ai-studio:log-collapsed'; - -function readStoredLogCollapsed() { - try { - return sessionStorage.getItem(LOG_COLLAPSED_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function persistLogCollapsed(logCollapsed: boolean) { - try { - sessionStorage.setItem(LOG_COLLAPSED_STORAGE_KEY, String(logCollapsed)); - } catch { - // storage unavailable - } -} - const emptyStore: ExecutionStore = { executionId: undefined, status: 'idle', streamUrl: undefined, nodeStates: {}, events: [], - logCollapsed: readStoredLogCollapsed(), + 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, logCollapsed: readStoredLogCollapsed() }); + useExecutionStore.setState((state) => ({ ...emptyStore, isLogCollapsed: state.isLogCollapsed })); } export function setExecutionStarted(executionId: string, streamUrl: string) { - // logCollapsed deliberately not persisted here - storage tracks user toggles only useExecutionStore.setState({ executionId, status: 'pending', streamUrl, nodeStates: {}, events: [], - logCollapsed: false, + isLogCollapsed: false, }); } @@ -122,13 +110,12 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record