Skip to content

Commit c9db847

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 c9db847

File tree

13 files changed

+1809
-255
lines changed

13 files changed

+1809
-255
lines changed
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
<script lang="ts">
2+
import { onMount, createEventDispatcher } from 'svelte';
3+
import { codeToHtml } from 'shiki';
4+
import { FLOW_CODE, getStepFromLine, FLOW_SECTIONS } from '$lib/data/flow-code';
5+
import type { createFlowState } from '$lib/stores/pgflow-state-improved.svelte';
6+
import StatusBadge from '$lib/components/StatusBadge.svelte';
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 status icon positioning
25+
const stepBlocks = $derived.by(() => {
26+
const blocks: Array<{ stepSlug: string; startLine: number; endLine: number }> = [];
27+
28+
for (const [stepSlug, section] of Object.entries(FLOW_SECTIONS)) {
29+
if (stepSlug === 'flow_config') continue; // Skip flow config
30+
if (section.startLine !== undefined && section.endLine !== undefined) {
31+
blocks.push({ stepSlug, startLine: section.startLine, endLine: section.endLine });
32+
}
33+
}
34+
35+
return blocks;
36+
});
37+
38+
// Helper to get status for a step badge
39+
function getStepStatus(stepSlug: string): string | null {
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 created
49+
if (!status) {
50+
return 'created';
51+
}
52+
53+
return status;
54+
}
55+
56+
onMount(async () => {
57+
// Generate syntax highlighted HTML using Shiki
58+
highlightedCode = await codeToHtml(FLOW_CODE, {
59+
lang: 'typescript',
60+
theme: 'night-owl',
61+
transformers: [
62+
{
63+
line(node, line) {
64+
// Add .line class to each line for click handling
65+
node.properties.class = 'line';
66+
}
67+
}
68+
]
69+
});
70+
71+
// Add click handlers to lines after rendering - need small delay
72+
setTimeout(() => {
73+
if (codeContainer) {
74+
setupClickHandlers();
75+
}
76+
}, 50);
77+
});
78+
79+
function setupClickHandlers() {
80+
if (!codeContainer) return;
81+
82+
// Find all line elements
83+
const lines = codeContainer.querySelectorAll('.line');
84+
lines.forEach((line, index) => {
85+
const lineNumber = index + 1;
86+
const stepSlug = getStepFromLine(lineNumber);
87+
88+
// Set data-step attribute for all lines (including flow_config)
89+
if (stepSlug) {
90+
(line as HTMLElement).setAttribute('data-step', stepSlug);
91+
(line as HTMLElement).setAttribute('data-line', String(lineNumber));
92+
(line as HTMLElement).style.cursor = 'pointer';
93+
94+
// Click handler
95+
line.addEventListener('click', () => {
96+
console.log('CodePanel: Line clicked, stepSlug:', stepSlug);
97+
// Clear hover state before navigating
98+
dispatch('step-hovered', { stepSlug: null });
99+
100+
// All sections (including flow_config) dispatch their slug
101+
dispatch('step-selected', { stepSlug });
102+
});
103+
104+
// Hover handlers - dispatch hover events
105+
line.addEventListener('mouseenter', () => {
106+
dispatch('step-hovered', { stepSlug });
107+
});
108+
109+
line.addEventListener('mouseleave', () => {
110+
dispatch('step-hovered', { stepSlug: null });
111+
});
112+
}
113+
});
114+
}
115+
116+
// Update line highlighting and borders based on step status, selected, and hovered steps
117+
$effect(() => {
118+
// Explicitly track these dependencies
119+
const currentSelectedStep = selectedStep;
120+
const currentHoveredStep = hoveredStep;
121+
const currentStepStatuses = flowState.stepStatuses;
122+
123+
if (!codeContainer) return;
124+
125+
const lines = codeContainer.querySelectorAll('.line');
126+
lines.forEach((line) => {
127+
const stepSlug = (line as HTMLElement).getAttribute('data-step');
128+
(line as HTMLElement).classList.remove('line-selected', 'line-hovered', 'line-dimmed');
129+
130+
if (stepSlug) {
131+
// Dimming: dim all lines except selected when selecting (including flow_config)
132+
if (currentSelectedStep && stepSlug !== currentSelectedStep) {
133+
(line as HTMLElement).classList.add('line-dimmed');
134+
}
135+
136+
// Hovered state (hovering) - blue highlight, no dimming (including flow_config)
137+
if (currentHoveredStep && stepSlug === currentHoveredStep) {
138+
(line as HTMLElement).classList.add('line-hovered');
139+
}
140+
141+
// Selected state (clicked) - blue background with dimming (not for flow_config)
142+
if (currentSelectedStep && stepSlug === currentSelectedStep) {
143+
(line as HTMLElement).classList.add('line-selected');
144+
}
145+
}
146+
});
147+
});
148+
</script>
149+
150+
<div class="code-panel-wrapper">
151+
<div class="code-panel" bind:this={codeContainer}>
152+
{@html highlightedCode}
153+
154+
<!-- Step status icons overlaid on code blocks -->
155+
{#each stepBlocks as block}
156+
{@const stepStatus = getStepStatus(block.stepSlug)}
157+
{#if stepStatus}
158+
{@const blockHeight = (block.endLine - block.startLine + 1) * 1.5}
159+
{@const blockTop = (block.startLine - 1) * 1.5}
160+
{@const iconTop = blockTop + blockHeight / 2}
161+
<div
162+
class="step-status-container"
163+
data-step={block.stepSlug}
164+
data-start-line={block.startLine}
165+
style="top: calc({iconTop}em + 12px);"
166+
>
167+
<span class="status-label status-{stepStatus}">{stepStatus}</span>
168+
<StatusBadge status={stepStatus} variant="icon-only" size="xl" />
169+
</div>
170+
{/if}
171+
{/each}
172+
</div>
173+
</div>
174+
175+
<style>
176+
.code-panel-wrapper {
177+
position: relative;
178+
}
179+
180+
.code-panel {
181+
overflow-x: auto;
182+
border-radius: 5px;
183+
font-size: 15px;
184+
background: #0d1117;
185+
position: relative;
186+
}
187+
188+
/* Override Shiki's default pre styling */
189+
.code-panel :global(pre) {
190+
margin: 0;
191+
padding: 12px 0;
192+
background: #0d1117 !important;
193+
border-radius: 5px;
194+
line-height: 1.5;
195+
}
196+
197+
.code-panel :global(code) {
198+
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Courier New', monospace;
199+
}
200+
201+
/* Line styling */
202+
.code-panel :global(.line) {
203+
display: inline-block;
204+
width: 100%;
205+
padding: 0 12px;
206+
transition: background-color 0.2s ease;
207+
}
208+
209+
/* Empty lines need content for background to show */
210+
.code-panel :global(.line:empty::after) {
211+
content: ' ';
212+
display: inline-block;
213+
}
214+
215+
/* Clickable lines */
216+
.code-panel :global(.line[data-step]) {
217+
cursor: pointer;
218+
}
219+
220+
/* Dimmed (when another step is selected) - lowest priority */
221+
.code-panel :global(.line-dimmed) {
222+
opacity: 0.4;
223+
transition: opacity 200ms ease;
224+
}
225+
226+
/* Ensure non-dimmed lines also transition smoothly */
227+
.code-panel :global(.line) {
228+
transition: opacity 200ms ease, background-color 200ms ease;
229+
}
230+
231+
/* Hover state - opaque blue highlight */
232+
.code-panel :global(.line-hovered) {
233+
background-color: rgba(88, 166, 255, 0.15) !important;
234+
opacity: 1 !important;
235+
}
236+
237+
/* Selected step (clicked by user) - stronger blue background */
238+
.code-panel :global(.line-selected) {
239+
background-color: rgba(88, 166, 255, 0.22) !important;
240+
opacity: 1 !important;
241+
}
242+
243+
/* Step status container */
244+
.step-status-container {
245+
position: absolute;
246+
right: 16px;
247+
transform: translateY(-50%);
248+
z-index: 10;
249+
pointer-events: none;
250+
transition: all 0.3s ease;
251+
display: flex;
252+
align-items: center;
253+
gap: 8px;
254+
}
255+
256+
/* Status labels */
257+
.status-label {
258+
font-size: 0.875rem;
259+
font-weight: 600;
260+
text-transform: lowercase;
261+
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Courier New', monospace;
262+
line-height: 1;
263+
}
264+
265+
.status-completed {
266+
color: #20a56f;
267+
}
268+
269+
.status-started {
270+
color: #5b8def;
271+
}
272+
273+
.status-failed {
274+
color: #f08060;
275+
}
276+
277+
.status-created {
278+
color: #607b75;
279+
}
280+
</style>

0 commit comments

Comments
 (0)