Skip to content

Commit c1e9b36

Browse files
jumskiclaude
andcommitted
feat: add interactive code panel with step explanations (Phase 4)
Implement code visualization with syntax highlighting and interactive step exploration. New Components: - CodePanel: Shiki-highlighted flow code with clickable lines - ExplanationPanel: Shows step dependencies, inputs, and returns on click - flow-code.ts: Simplified educational code snippet with line-to-step mapping Enhanced Components: - DAGVisualization: Added click handling and selection states - DebugPanel: Auto-scroll to selected step with smooth animations - Page layout: 55/45 split, all panels synchronized via step-selected events Interactions: - Click code line or DAG node → shows explanation + scrolls debug panel - Visual states: active (executing) vs selected (clicked) - ESC or outside click dismisses explanation panel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3f40fb5 commit c1e9b36

File tree

6 files changed

+1133
-200
lines changed

6 files changed

+1133
-200
lines changed
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
<script lang="ts">
2+
import { onMount, createEventDispatcher } from 'svelte';
3+
import { codeToHtml } from 'shiki';
4+
import { FLOW_CODE, getStepFromLine, LINE_TO_STEP_MAP } from '$lib/data/flow-code';
5+
import type { createFlowState } from '$lib/stores/pgflow-state-improved.svelte';
6+
import { Badge } from '$lib/components/ui/badge';
7+
8+
interface Props {
9+
flowState: ReturnType<typeof createFlowState>;
10+
selectedStep: string | null;
11+
hoveredStep: string | null;
12+
}
13+
14+
let { flowState, selectedStep, hoveredStep }: Props = $props();
15+
16+
const dispatch = createEventDispatcher<{
17+
'step-selected': { stepSlug: string };
18+
'step-hovered': { stepSlug: string | null };
19+
}>();
20+
21+
let highlightedCode = $state('');
22+
let codeContainer: HTMLElement | undefined = $state(undefined);
23+
24+
// Calculate step blocks (groups of lines) for badge positioning
25+
const stepBlocks = $derived.by(() => {
26+
const blocks: Array<{ stepSlug: string; startLine: number; endLine: number }> = [];
27+
28+
for (const [range, stepSlug] of Object.entries(LINE_TO_STEP_MAP)) {
29+
if (stepSlug === 'flow_config') continue; // Skip flow config
30+
const [start, end] = range.split('-').map(Number);
31+
blocks.push({ stepSlug, startLine: start, endLine: end });
32+
}
33+
34+
return blocks;
35+
});
36+
37+
// Helper to get badge label and color for a step
38+
// Colors match DAG node colors from DAGVisualization.svelte
39+
function getStepBadgeInfo(stepSlug: string) {
40+
const status = flowState.stepStatuses[stepSlug];
41+
const hasFlowStarted = flowState.status !== 'idle';
42+
43+
// Don't show badge if flow hasn't started yet
44+
if (!hasFlowStarted) {
45+
return null;
46+
}
47+
48+
// If flow has started but this step has no status yet, show as queued/pending
49+
if (!status) {
50+
return {
51+
label: 'queued',
52+
shouldPulse: false,
53+
customClass: 'bg-[#3d524d] hover:bg-[#3d524d] border-[#607b75]'
54+
};
55+
}
56+
57+
switch (status) {
58+
case 'started':
59+
case 'in_progress':
60+
return {
61+
label: 'running',
62+
shouldPulse: true,
63+
customClass: 'bg-[#3b5bdb] hover:bg-[#3b5bdb] border-[#5b8def]'
64+
};
65+
case 'completed':
66+
return {
67+
label: 'completed',
68+
shouldPulse: false,
69+
customClass: 'bg-[#177a51] hover:bg-[#177a51] border-[#20a56f]'
70+
};
71+
case 'failed':
72+
return {
73+
label: 'failed',
74+
shouldPulse: false,
75+
customClass: 'bg-[#c94a2e] hover:bg-[#c94a2e] border-[#f08060]'
76+
};
77+
default:
78+
return {
79+
label: status,
80+
shouldPulse: false,
81+
customClass: 'bg-[#3d524d] hover:bg-[#3d524d] border-[#607b75]'
82+
};
83+
}
84+
}
85+
86+
onMount(async () => {
87+
// Generate syntax highlighted HTML using Shiki
88+
highlightedCode = await codeToHtml(FLOW_CODE, {
89+
lang: 'typescript',
90+
theme: 'github-dark',
91+
transformers: [
92+
{
93+
line(node, line) {
94+
// Add .line class to each line for click handling
95+
node.properties.class = 'line';
96+
}
97+
}
98+
]
99+
});
100+
101+
// Add click handlers to lines after rendering - need small delay
102+
setTimeout(() => {
103+
if (codeContainer) {
104+
setupClickHandlers();
105+
}
106+
}, 50);
107+
});
108+
109+
function setupClickHandlers() {
110+
if (!codeContainer) return;
111+
112+
// Find all line elements
113+
const lines = codeContainer.querySelectorAll('.line');
114+
lines.forEach((line, index) => {
115+
const lineNumber = index + 1;
116+
const stepSlug = getStepFromLine(lineNumber);
117+
118+
// Set data-step attribute for all lines (including flow_config)
119+
if (stepSlug) {
120+
(line as HTMLElement).setAttribute('data-step', stepSlug);
121+
(line as HTMLElement).setAttribute('data-line', String(lineNumber));
122+
}
123+
124+
// Only make step lines clickable (not flow_config)
125+
if (stepSlug && stepSlug !== 'flow_config') {
126+
(line as HTMLElement).style.cursor = 'pointer';
127+
128+
// Click handler
129+
line.addEventListener('click', () => {
130+
console.log('CodePanel: Line clicked, stepSlug:', stepSlug);
131+
dispatch('step-selected', { stepSlug });
132+
});
133+
134+
// Hover handlers - dispatch hover events
135+
line.addEventListener('mouseenter', () => {
136+
dispatch('step-hovered', { stepSlug });
137+
});
138+
139+
line.addEventListener('mouseleave', () => {
140+
dispatch('step-hovered', { stepSlug: null });
141+
});
142+
}
143+
});
144+
}
145+
146+
// Update line highlighting and borders based on step status, selected, and hovered steps
147+
$effect(() => {
148+
// Explicitly track these dependencies
149+
const currentSelectedStep = selectedStep;
150+
const currentHoveredStep = hoveredStep;
151+
const currentStepStatuses = flowState.stepStatuses;
152+
153+
if (!codeContainer) return;
154+
155+
const lines = codeContainer.querySelectorAll('.line');
156+
lines.forEach((line) => {
157+
const stepSlug = (line as HTMLElement).getAttribute('data-step');
158+
(line as HTMLElement).classList.remove(
159+
'line-selected',
160+
'line-hovered',
161+
'line-dimmed',
162+
'line-status-queued',
163+
'line-status-running',
164+
'line-status-completed',
165+
'line-status-failed'
166+
);
167+
168+
if (stepSlug && stepSlug !== 'flow_config') {
169+
// Add status-based border classes
170+
const status = currentStepStatuses[stepSlug];
171+
if (status === 'started' || status === 'in_progress') {
172+
(line as HTMLElement).classList.add('line-status-running');
173+
} else if (status === 'completed') {
174+
(line as HTMLElement).classList.add('line-status-completed');
175+
} else if (status === 'failed') {
176+
(line as HTMLElement).classList.add('line-status-failed');
177+
} else if (!status && flowState.status !== 'idle') {
178+
// Flow has started but this step hasn't - show as queued
179+
(line as HTMLElement).classList.add('line-status-queued');
180+
}
181+
182+
// Dimming: dim all lines except hovered when hovering
183+
if (currentHoveredStep && stepSlug !== currentHoveredStep) {
184+
(line as HTMLElement).classList.add('line-dimmed');
185+
}
186+
187+
// Selected state (clicked) - blue background
188+
if (currentSelectedStep && stepSlug === currentSelectedStep) {
189+
(line as HTMLElement).classList.add('line-selected');
190+
}
191+
// Hovered state (hovering) - lighter highlight, overrides dimmed
192+
if (currentHoveredStep && stepSlug === currentHoveredStep) {
193+
(line as HTMLElement).classList.add('line-hovered');
194+
}
195+
}
196+
});
197+
});
198+
</script>
199+
200+
<div class="code-panel-wrapper">
201+
<div class="code-panel" bind:this={codeContainer}>
202+
{@html highlightedCode}
203+
204+
<!-- Step status badges overlaid on code blocks -->
205+
{#each stepBlocks as block}
206+
{@const badgeInfo = getStepBadgeInfo(block.stepSlug)}
207+
{#if badgeInfo}
208+
<div
209+
class="step-badge"
210+
data-step={block.stepSlug}
211+
data-start-line={block.startLine}
212+
style="top: calc({(block.startLine - 1) * 1.5}em + 20px);"
213+
class:badge-pulse={badgeInfo.shouldPulse}
214+
>
215+
<Badge class="text-xs text-white font-bold rounded-sm {badgeInfo.customClass}">
216+
{badgeInfo.label}
217+
</Badge>
218+
</div>
219+
{/if}
220+
{/each}
221+
</div>
222+
</div>
223+
224+
<style>
225+
.code-panel-wrapper {
226+
position: relative;
227+
}
228+
229+
.code-panel {
230+
overflow-x: auto;
231+
border-radius: 5px;
232+
font-size: 15px;
233+
background: #0d1117;
234+
position: relative;
235+
}
236+
237+
/* Override Shiki's default pre styling */
238+
.code-panel :global(pre) {
239+
margin: 0;
240+
padding: 12px 0;
241+
background: #0d1117 !important;
242+
border-radius: 5px;
243+
line-height: 1.5;
244+
}
245+
246+
.code-panel :global(code) {
247+
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Courier New', monospace;
248+
}
249+
250+
/* Line styling */
251+
.code-panel :global(.line) {
252+
display: inline-block;
253+
width: 100%;
254+
padding: 0 12px;
255+
transition: background-color 0.2s ease;
256+
}
257+
258+
/* Clickable lines */
259+
.code-panel :global(.line[data-step]) {
260+
cursor: pointer;
261+
}
262+
263+
/* Dimmed (when another step is selected) - lowest priority */
264+
.code-panel :global(.line-dimmed) {
265+
opacity: 0.4;
266+
transition: opacity 200ms ease;
267+
}
268+
269+
/* Ensure non-dimmed lines also transition smoothly */
270+
.code-panel :global(.line) {
271+
transition: opacity 200ms ease, background-color 200ms ease;
272+
}
273+
274+
/* Status-based left borders (matches DAG node colors) - NO background color */
275+
.code-panel :global(.line-status-queued) {
276+
border-left: 4px solid #607b75;
277+
padding-left: 8px !important;
278+
}
279+
280+
.code-panel :global(.line-status-running) {
281+
border-left: 4px solid #5b8def;
282+
padding-left: 8px !important;
283+
}
284+
285+
.code-panel :global(.line-status-completed) {
286+
border-left: 4px solid #20a56f;
287+
padding-left: 8px !important;
288+
}
289+
290+
.code-panel :global(.line-status-failed) {
291+
border-left: 4px solid #f08060;
292+
padding-left: 8px !important;
293+
}
294+
295+
/* Hover state - subtle light blue highlight - overrides dimmed */
296+
.code-panel :global(.line-hovered) {
297+
background-color: rgba(88, 166, 255, 0.08) !important;
298+
opacity: 1 !important;
299+
}
300+
301+
/* Selected step (clicked by user) - blue background, no border (border reserved for status) */
302+
.code-panel :global(.line-selected) {
303+
background-color: rgba(88, 166, 255, 0.18) !important;
304+
}
305+
306+
/* Step status badges */
307+
.step-badge {
308+
position: absolute;
309+
right: 12px;
310+
z-index: 10;
311+
pointer-events: none;
312+
transition: all 0.3s ease;
313+
}
314+
315+
/* Pulsing animation for active/running badges */
316+
.badge-pulse {
317+
animation: badge-pulse 2s ease-in-out infinite;
318+
}
319+
320+
@keyframes badge-pulse {
321+
0%,
322+
100% {
323+
opacity: 1;
324+
transform: scale(1);
325+
}
326+
50% {
327+
opacity: 0.7;
328+
transform: scale(1.05);
329+
}
330+
}
331+
</style>

0 commit comments

Comments
 (0)