From 23935de4b4cc0c5877646bc3a6d8c5dddad8fec9 Mon Sep 17 00:00:00 2001 From: Zach Zhong Date: Wed, 24 Dec 2025 21:16:42 -0800 Subject: [PATCH 1/4] feat(bubble-studio): integrate Autolinker for enhanced URL detection in execution logs --- apps/bubble-studio/package.json | 1 + .../execution_logs/JsonRenderer.tsx | 115 ++++++++++++------ pnpm-lock.yaml | 14 +++ 3 files changed, 90 insertions(+), 40 deletions(-) diff --git a/apps/bubble-studio/package.json b/apps/bubble-studio/package.json index 9150779b..c4f03217 100644 --- a/apps/bubble-studio/package.json +++ b/apps/bubble-studio/package.json @@ -24,6 +24,7 @@ "@tanstack/router-devtools": "^1.133.28", "@types/marked": "^6.0.0", "@xyflow/react": "^12.8.2", + "autolinker": "^4.1.5", "browser-image-compression": "^2.0.2", "jszip": "^3.10.1", "lucide-react": "^0.544.0", diff --git a/apps/bubble-studio/src/components/execution_logs/JsonRenderer.tsx b/apps/bubble-studio/src/components/execution_logs/JsonRenderer.tsx index aa1de285..928b3f5e 100644 --- a/apps/bubble-studio/src/components/execution_logs/JsonRenderer.tsx +++ b/apps/bubble-studio/src/components/execution_logs/JsonRenderer.tsx @@ -1,5 +1,6 @@ import { useMemo, memo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; +import Autolinker from 'autolinker'; import { getCacheKey, simplifyObjectForContext, @@ -103,52 +104,86 @@ function parseJSONString(str: string): unknown | null { } } +/** + * Autolinker instance configured for URL detection + * Handles trailing punctuation correctly (e.g., won't include period at end of sentence) + */ +const autolinker = new Autolinker({ + urls: true, + email: false, + phone: false, + mention: false, + hashtag: false, + stripPrefix: false, + stripTrailingSlash: false, + decodePercentEncoding: false, + newWindow: true, +}); + /** * Detect URLs in text and convert them to React components with clickable links - * Similar to makeLinksClickable but returns React components + * Uses Autolinker for accurate URL detection that handles trailing punctuation * Adds download button for file URLs */ function renderStringWithLinks(text: string): React.ReactNode { - const urlRegex = /(https?:\/\/[^\s"<>]+)/g; - const parts = text.split(urlRegex); + const matches = autolinker.parse(text); - return ( - <> - {parts.map((part, index) => { - // Check if part is a URL by checking if it starts with http:// or https:// - const isUrl = part.startsWith('http://') || part.startsWith('https://'); - if (isUrl) { - // Sanitize href to prevent javascript: and dangerous data URLs - const safeHref = - !part.toLowerCase().startsWith('javascript:') && - !part.toLowerCase().startsWith('data:text/html') && - !part.toLowerCase().startsWith('data:application/javascript') - ? part - : undefined; - - const isDownloadable = safeHref && isDownloadableFileUrl(safeHref); - - return ( - - - {part} - - {isDownloadable && } - - ); - } - return {part}; - })} - - ); + if (matches.length === 0) { + return <>{text}; + } + + const elements: React.ReactNode[] = []; + let lastIndex = 0; + + matches.forEach((match, index) => { + const offset = match.getOffset(); + const matchedText = match.getMatchedText(); + const url = match.getAnchorHref(); + + // Add text before this match + if (offset > lastIndex) { + elements.push( + {text.slice(lastIndex, offset)} + ); + } + + // Sanitize href to prevent javascript: and dangerous data URLs + const safeHref = + url && + !url.toLowerCase().startsWith('javascript:') && + !url.toLowerCase().startsWith('data:text/html') && + !url.toLowerCase().startsWith('data:application/javascript') + ? url + : undefined; + + const isDownloadable = safeHref && isDownloadableFileUrl(safeHref); + + elements.push( + + + {matchedText} + + {isDownloadable && safeHref && } + + ); + + lastIndex = offset + matchedText.length; + }); + + // Add remaining text after last match + if (lastIndex < text.length) { + elements.push({text.slice(lastIndex)}); + } + + return <>{elements}; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d874effe..3aa16675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: '@xyflow/react': specifier: ^12.8.2 version: 12.8.6(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + autolinker: + specifier: ^4.1.5 + version: 4.1.5 browser-image-compression: specifier: ^2.0.2 version: 2.0.2 @@ -7270,6 +7273,13 @@ packages: integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, } + autolinker@4.1.5: + resolution: + { + integrity: sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==, + } + engines: { pnpm: '>=10.10.0' } + autoprefixer@10.4.21: resolution: { @@ -22749,6 +22759,10 @@ snapshots: asynckit@0.4.0: {} + autolinker@4.1.5: + dependencies: + tslib: 2.8.1 + autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.26.3 From 43f55161805dba621a511c3404a09b58a6880262 Mon Sep 17 00:00:00 2001 From: Zach Zhong Date: Wed, 24 Dec 2025 21:35:11 -0800 Subject: [PATCH 2/4] feat(bubble-studio): enhance execution output experience by managing tab selections --- .../src/hooks/useRunExecution.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/bubble-studio/src/hooks/useRunExecution.ts b/apps/bubble-studio/src/hooks/useRunExecution.ts index 86b83ec5..6f17d5fc 100644 --- a/apps/bubble-studio/src/hooks/useRunExecution.ts +++ b/apps/bubble-studio/src/hooks/useRunExecution.ts @@ -13,6 +13,7 @@ import { findBubbleByVariableId } from '@/utils/bubbleUtils'; import { useSubscription } from '@/hooks/useSubscription'; import { BubbleFlowDetailsResponse } from '@bubblelab/shared-schemas'; import { useUIStore } from '@/stores/uiStore'; +import { getLiveOutputStore } from '@/stores/liveOutputStore'; import { useLiveOutput } from './useLiveOutput'; import { validateInputs, @@ -82,7 +83,14 @@ export function useRunExecution( // Start execution in store getExecutionStore(flowId).startExecution(); + // Open output panel and select first tab (index 0) at execution start + // This gives users the "adrenaline rush" of seeing output stream in from the beginning useUIStore.getState().setConsolidatedPanelTab('output'); + // Reset to first tab - when first bubble event comes in, it will appear here + getLiveOutputStore(flowId) + ?.getState() + .setSelectedTab({ kind: 'item', index: 0 }); + const abortController = new AbortController(); getExecutionStore(flowId).setAbortController(abortController); @@ -148,7 +156,9 @@ export function useRunExecution( // Mark bubble as running getExecutionStore(flowId).setBubbleRunning(bubbleId); - selectBubbleInConsole(bubbleId); + // Note: We intentionally don't call selectBubbleInConsole here + // to avoid jumping the tab on every bubble execution. + // Users can watch output stream in without jarring tab switches. // Highlight the line range in the editor (validate line numbers) if ( @@ -207,6 +217,10 @@ export function useRunExecution( if (!success) { getExecutionStore(flowId).setBubbleError(bubbleId); } + + // Jump to this bubble's tab on completion (not on start) + // This shows users the output when it's ready, for maximum adrenaline + selectBubbleInConsole(bubbleId); } } } @@ -221,7 +235,9 @@ export function useRunExecution( // Mark function call as running getExecutionStore(flowId).setBubbleRunning(functionId); - selectBubbleInConsole(functionId); + // Note: We intentionally don't call selectBubbleInConsole here + // to avoid jumping the tab on every function call. + // Users can watch output stream in without jarring tab switches. // Highlight the line range in the editor if line number is available if (eventData.lineNumber && eventData.lineNumber > 0) { @@ -251,6 +267,10 @@ export function useRunExecution( // For now, we'll assume success unless explicitly marked as error // This could be enhanced to check functionOutput for error indicators getExecutionStore(flowId).setBubbleResult(functionId, true); + + // Jump to this function's tab on completion (not on start) + // This shows users the output when it's ready, for maximum adrenaline + selectBubbleInConsole(functionId); } } From 159a11730867af10eb02a065c008ffe48c073523 Mon Sep 17 00:00:00 2001 From: Zach Zhong Date: Wed, 24 Dec 2025 21:47:00 -0800 Subject: [PATCH 3/4] feat(bubble-studio): implement step hierarchy and execution status indicators in AllEventsView --- .../execution_logs/AllEventsView.tsx | 295 ++++++++++++++++-- 1 file changed, 276 insertions(+), 19 deletions(-) diff --git a/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx b/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx index db473d85..66b8f200 100644 --- a/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx +++ b/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx @@ -1,6 +1,13 @@ -import React, { useRef } from 'react'; +import React, { useRef, useMemo } from 'react'; import { PlayIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; -import { Sparkles } from 'lucide-react'; +import { + Sparkles, + CheckCircle2, + XCircle, + Loader2, + Circle, + ChevronRight, +} from 'lucide-react'; import type { StreamingLogEvent } from '@bubblelab/shared-schemas'; import { findLogoForBubble } from '../../lib/integrations'; import { getVariableNameForDisplay } from '../../utils/bubbleUtils'; @@ -8,7 +15,9 @@ import { useBubbleFlow } from '../../hooks/useBubbleFlow'; import { useLiveOutput } from '../../hooks/useLiveOutput'; import { usePearlChatStore } from '../../hooks/usePearlChatStore'; import { useUIStore } from '../../stores/uiStore'; +import { useExecutionStore } from '../../stores/executionStore'; import type { TabType } from '../../stores/liveOutputStore'; +import { extractStepGraph, type StepData } from '../../utils/workflowToSteps'; interface AllEventsViewProps { orderedItems: Array< @@ -68,10 +77,87 @@ export default function AllEventsView({ const pearl = usePearlChatStore(flowId); const { openConsolidatedPanelWith } = useUIStore(); + // Get execution state for bubble status + const executionState = useExecutionStore(flowId); + const { runningBubbles, completedBubbles, bubbleWithError, bubbleResults } = + executionState; + const eventsEndRef = useRef(null); const tabsRef = useRef(null); const contentScrollRef = useRef(null); + // Extract step graph from workflow + const stepGraph = useMemo(() => { + if (!currentFlow.data?.workflow || !bubbleParameters) { + return { steps: [], edges: [] }; + } + // Convert bubbleParameters to the expected format (Record) + const bubblesRecord: Record = {}; + for (const [key, value] of Object.entries(bubbleParameters)) { + bubblesRecord[parseInt(key, 10)] = value; + } + return extractStepGraph(currentFlow.data.workflow, bubblesRecord); + }, [currentFlow.data?.workflow, bubbleParameters]); + + // Helper to get bubble execution status + const getBubbleStatus = ( + variableId: string + ): 'pending' | 'running' | 'complete' | 'error' => { + // Check for error state first (either via bubbleWithError or failed result) + if (bubbleWithError === variableId || bubbleResults[variableId] === false) + return 'error'; + // Check if completed + if (completedBubbles[variableId]) return 'complete'; + // Check if running + if (runningBubbles.has(variableId)) return 'running'; + return 'pending'; + }; + + // Helper to get step execution status (based on its bubbles) + const getStepStatus = ( + step: StepData + ): 'pending' | 'running' | 'complete' | 'error' => { + const bubbleStatuses = step.bubbleIds.map((id) => + getBubbleStatus(String(id)) + ); + if (bubbleStatuses.includes('error')) return 'error'; + if (bubbleStatuses.includes('running')) return 'running'; + if ( + bubbleStatuses.every((s) => s === 'complete') && + bubbleStatuses.length > 0 + ) + return 'complete'; + if (bubbleStatuses.some((s) => s === 'complete')) return 'running'; // Partially complete + return 'pending'; + }; + + // Status indicator component + const StatusIndicator = ({ + status, + size = 'normal', + }: { + status: 'pending' | 'running' | 'complete' | 'error'; + size?: 'small' | 'normal'; + }) => { + const sizeClass = size === 'small' ? 'w-3 h-3' : 'w-3.5 h-3.5'; + switch (status) { + case 'running': + return ( + + ); + case 'complete': + return ; + case 'error': + return ; + default: + return ( + + ); + } + }; + // Count info events (exclude log_line from info grouping) const infoCount = events.filter( (e) => e.type === 'info' || e.type === 'debug' || e.type === 'trace' @@ -148,32 +234,203 @@ export default function AllEventsView({ return (
- {/* Vertical Sidebar with Tabs */} + {/* Vertical Sidebar with Step Hierarchy */}
-
- {allTabs.map((tab) => { - const isSelected = isTabSelected(tab.type); - return ( + {/* Header */} +
+ + Execution Steps + +
+ + {/* Step Hierarchy */} +
+ {stepGraph.steps.length > 0 + ? // Show steps with bubbles grouped under them + stepGraph.steps.map((step, stepIndex) => { + const stepStatus = getStepStatus(step); + // Find bubble tabs that belong to this step + const stepBubbleTabs = allTabs.filter((tab) => { + if (tab.type.kind !== 'item') return false; + const item = orderedItems[tab.type.index]; + if (item?.kind !== 'group') return false; + return step.bubbleIds.includes(parseInt(item.name, 10)); + }); + + return ( +
+ {/* Step Header */} +
+ {/* Step number */} +
+ {stepIndex + 1} +
+ {/* Step name */} +
+ + {step.functionName} + + {step.description && ( + + {step.description} + + )} +
+ +
+ + {/* Bubbles under this step */} +
+ {stepBubbleTabs.map((tab) => { + const isSelected = isTabSelected(tab.type); + const item = + orderedItems[ + (tab.type as { kind: 'item'; index: number }).index + ]; + const bubbleStatus = + item?.kind === 'group' + ? getBubbleStatus(item.name) + : 'pending'; + + return ( + + ); + })} +
+
+ ); + }) + : // Fallback: Show flat list of bubbles (no step info available) + allTabs + .filter((t) => t.type.kind === 'item') + .map((tab) => { + const isSelected = isTabSelected(tab.type); + const item = + orderedItems[ + (tab.type as { kind: 'item'; index: number }).index + ]; + const bubbleStatus = + item?.kind === 'group' + ? getBubbleStatus(item.name) + : 'pending'; + + return ( + + ); + })} + + {/* Results Tab - Always at the end */} + {!isRunning && ( +
- ); - })} +
+ )}
From 47b35891e07e69972ff8e600ebce3ff598cb8e4b Mon Sep 17 00:00:00 2001 From: Zach Zhong Date: Thu, 25 Dec 2025 05:02:41 -0800 Subject: [PATCH 4/4] feat(bubble-studio): add expandable bubble functionality and enhance step selection in AllEventsView --- .../execution_logs/AllEventsView.tsx | 282 +++++++++++++++--- .../src/utils/workflowToSteps.ts | 13 +- 2 files changed, 242 insertions(+), 53 deletions(-) diff --git a/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx b/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx index 66b8f200..571ace34 100644 --- a/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx +++ b/apps/bubble-studio/src/components/execution_logs/AllEventsView.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useMemo } from 'react'; +import React, { useRef, useMemo, useState } from 'react'; import { PlayIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import { Sparkles, @@ -6,6 +6,7 @@ import { XCircle, Loader2, Circle, + ChevronDown, ChevronRight, } from 'lucide-react'; import type { StreamingLogEvent } from '@bubblelab/shared-schemas'; @@ -71,6 +72,7 @@ export default function AllEventsView({ setSelectedTab, selectedEventIndexByVariableId, setSelectedEventIndex, + selectBubbleInConsole, } = useLiveOutput(flowId); // Pearl chat integration for error fixing @@ -86,6 +88,23 @@ export default function AllEventsView({ const tabsRef = useRef(null); const contentScrollRef = useRef(null); + // State for expanded bubbles (bubbles with sub-bubbles can be expanded) + const [expandedBubbles, setExpandedBubbles] = useState>( + new Set() + ); + + const toggleBubbleExpansion = (bubbleId: string) => { + setExpandedBubbles((prev) => { + const next = new Set(prev); + if (next.has(bubbleId)) { + next.delete(bubbleId); + } else { + next.add(bubbleId); + } + return next; + }); + }; + // Extract step graph from workflow const stepGraph = useMemo(() => { if (!currentFlow.data?.workflow || !bubbleParameters) { @@ -113,10 +132,18 @@ export default function AllEventsView({ return 'pending'; }; - // Helper to get step execution status (based on its bubbles) + // Helper to get step execution status (based on its bubbles OR its own variableId for transformation functions) const getStepStatus = ( step: StepData ): 'pending' | 'running' | 'complete' | 'error' => { + // For transformation functions or steps with no bubbles, check the step's own variableId + const stepVariableId = + step.variableId ?? step.transformationData?.variableId; + if (step.bubbleIds.length === 0 && stepVariableId) { + return getBubbleStatus(String(stepVariableId)); + } + + // For steps with bubbles, check all bubble statuses const bubbleStatuses = step.bubbleIds.map((id) => getBubbleStatus(String(id)) ); @@ -260,10 +287,48 @@ export default function AllEventsView({ return step.bubbleIds.includes(parseInt(item.name, 10)); }); + // Get the step's variableId (from step.variableId for regular functions, or transformationData for transformations) + const stepVariableId = + step.variableId ?? step.transformationData?.variableId; + + // Check if this step is selected + const isStepSelected = (() => { + if (stepVariableId) { + if (selectedTab.kind === 'item') { + const item = orderedItems[selectedTab.index]; + return ( + item?.kind === 'group' && + item.name === String(stepVariableId) + ); + } + return false; + } + return stepBubbleTabs.some((tab) => isTabSelected(tab.type)); + })(); + + // Click handler for step - selects the step's output in console + const handleStepClick = () => { + // Use step's variableId if available + if (stepVariableId) { + selectBubbleInConsole(String(stepVariableId)); + } else if (stepBubbleTabs.length > 0) { + // Fallback: select the first bubble + setSelectedTab(stepBubbleTabs[0].type); + } + }; + return (
- {/* Step Header */} -
+ {/* Step Header - Clickable */} + {/* Bubbles under this step */}
@@ -303,52 +374,171 @@ export default function AllEventsView({ orderedItems[ (tab.type as { kind: 'item'; index: number }).index ]; + const bubbleId = + item?.kind === 'group' ? item.name : ''; const bubbleStatus = item?.kind === 'group' ? getBubbleStatus(item.name) : 'pending'; - return ( - + {/* Connecting dot */} +
+ + {/* Expand/collapse button for bubbles with sub-bubbles */} + {hasSubBubbles && ( + + )} + + {/* Bubble icon */} +
+ {tab.icon} +
+ + {/* Bubble name */} + + {tab.label} + + + {/* Sub-bubble count badge */} + {hasSubBubbles && ( + + {subBubbleDeps.length} + + )} + + {/* Status */} + + + + {/* Sub-bubbles - nested under parent when expanded */} + {hasSubBubbles && + isExpanded && + subBubbleTabs.length > 0 && ( +
+ {subBubbleTabs.map((subTab) => { + const subIsSelected = isTabSelected( + subTab.type + ); + const subItem = + orderedItems[ + ( + subTab.type as { + kind: 'item'; + index: number; + } + ).index + ]; + const subBubbleStatus = + subItem?.kind === 'group' + ? getBubbleStatus(subItem.name) + : 'pending'; + + return ( + + ); + })} +
+ )} +
); })}
@@ -391,9 +581,6 @@ export default function AllEventsView({ {tab.label} - {isSelected && ( -
- )} ); })} @@ -425,9 +612,6 @@ export default function AllEventsView({ ({totalGlobalCount}) )} - {selectedTab.kind === 'results' && ( -
- )}
)} diff --git a/apps/bubble-studio/src/utils/workflowToSteps.ts b/apps/bubble-studio/src/utils/workflowToSteps.ts index a57bf880..1ec1424f 100644 --- a/apps/bubble-studio/src/utils/workflowToSteps.ts +++ b/apps/bubble-studio/src/utils/workflowToSteps.ts @@ -14,9 +14,10 @@ export interface StepData { location: { startLine: number; endLine: number }; bubbleIds: number[]; // IDs of bubbles inside this step controlFlowNodes: WorkflowNode[]; // if/for/while nodes for edge generation + variableId?: number; // Unique variable ID for tracking execution in console // New: layout / structural metadata - level: number; // 0,1,2,... step “row” in the flow + level: number; // 0,1,2,... step "row" in the flow branchIndex?: number; // 0,1,2,... within a level (for siblings) // Branch information for hierarchical layout (kept for compatibility) @@ -90,7 +91,8 @@ export function extractStepGraph( bubbleIds: number[], controlFlowNodes: WorkflowNode[], parentFrontier: Frontier, - ctx: ProcessContext + ctx: ProcessContext, + variableId?: number ): StepData { const parentStepId = parentFrontier.parents.length > 0 ? parentFrontier.parents[0] : undefined; @@ -107,6 +109,7 @@ export function extractStepGraph( parentStepId, branchType: ctx.branchType, branchLabel: ctx.edgeLabel, + variableId, }; return step; @@ -179,7 +182,8 @@ export function extractStepGraph( bubbleIds, controlFlowNodes, frontier, - ctx + ctx, + functionCallNode.variableId ); steps.push(step); @@ -366,7 +370,8 @@ export function extractStepGraph( bubbleIds, controlFlowNodes, parentFrontier, - { frontier, branchType: 'sequential' } + { frontier, branchType: 'sequential' }, + fnChild.variableId ); steps.push(step); connectFrontierToStep(