Skip to content

Commit 1f7f9c7

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 1f7f9c7

File tree

12 files changed

+1605
-255
lines changed

12 files changed

+1605
-255
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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+
if (stepSlug === 'flow_config') {
101+
// Clicking flow config deselects any step
102+
dispatch('step-selected', { stepSlug: null });
103+
} else {
104+
dispatch('step-selected', { stepSlug });
105+
}
106+
});
107+
108+
// Hover handlers - dispatch hover events
109+
line.addEventListener('mouseenter', () => {
110+
dispatch('step-hovered', { stepSlug });
111+
});
112+
113+
line.addEventListener('mouseleave', () => {
114+
dispatch('step-hovered', { stepSlug: null });
115+
});
116+
}
117+
});
118+
}
119+
120+
// Update line highlighting and borders based on step status, selected, and hovered steps
121+
$effect(() => {
122+
// Explicitly track these dependencies
123+
const currentSelectedStep = selectedStep;
124+
const currentHoveredStep = hoveredStep;
125+
const currentStepStatuses = flowState.stepStatuses;
126+
127+
if (!codeContainer) return;
128+
129+
const lines = codeContainer.querySelectorAll('.line');
130+
lines.forEach((line) => {
131+
const stepSlug = (line as HTMLElement).getAttribute('data-step');
132+
(line as HTMLElement).classList.remove('line-selected', 'line-hovered', 'line-dimmed');
133+
134+
if (stepSlug) {
135+
// Dimming: dim all lines except selected when selecting (including flow_config)
136+
if (currentSelectedStep && stepSlug !== currentSelectedStep) {
137+
(line as HTMLElement).classList.add('line-dimmed');
138+
}
139+
140+
// Hovered state (hovering) - blue highlight, no dimming (including flow_config)
141+
if (currentHoveredStep && stepSlug === currentHoveredStep) {
142+
(line as HTMLElement).classList.add('line-hovered');
143+
}
144+
145+
// Selected state (clicked) - blue background with dimming (not for flow_config)
146+
if (currentSelectedStep && stepSlug === currentSelectedStep) {
147+
(line as HTMLElement).classList.add('line-selected');
148+
}
149+
}
150+
});
151+
});
152+
</script>
153+
154+
<div class="code-panel-wrapper">
155+
<div class="code-panel" bind:this={codeContainer}>
156+
{@html highlightedCode}
157+
158+
<!-- Step status icons overlaid on code blocks -->
159+
{#each stepBlocks as block}
160+
{@const stepStatus = getStepStatus(block.stepSlug)}
161+
{#if stepStatus}
162+
{@const blockHeight = (block.endLine - block.startLine + 1) * 1.5}
163+
{@const blockTop = (block.startLine - 1) * 1.5}
164+
{@const iconTop = blockTop + blockHeight / 2}
165+
<div
166+
class="step-status-container"
167+
data-step={block.stepSlug}
168+
data-start-line={block.startLine}
169+
style="top: calc({iconTop}em + 12px);"
170+
>
171+
<span class="status-label status-{stepStatus}">{stepStatus}</span>
172+
<StatusBadge status={stepStatus} variant="icon-only" size="xl" />
173+
</div>
174+
{/if}
175+
{/each}
176+
</div>
177+
</div>
178+
179+
<style>
180+
.code-panel-wrapper {
181+
position: relative;
182+
}
183+
184+
.code-panel {
185+
overflow-x: auto;
186+
border-radius: 5px;
187+
font-size: 15px;
188+
background: #0d1117;
189+
position: relative;
190+
}
191+
192+
/* Override Shiki's default pre styling */
193+
.code-panel :global(pre) {
194+
margin: 0;
195+
padding: 12px 0;
196+
background: #0d1117 !important;
197+
border-radius: 5px;
198+
line-height: 1.5;
199+
}
200+
201+
.code-panel :global(code) {
202+
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Courier New', monospace;
203+
}
204+
205+
/* Line styling */
206+
.code-panel :global(.line) {
207+
display: inline-block;
208+
width: 100%;
209+
padding: 0 12px;
210+
transition: background-color 0.2s ease;
211+
}
212+
213+
/* Empty lines need content for background to show */
214+
.code-panel :global(.line:empty::after) {
215+
content: ' ';
216+
display: inline-block;
217+
}
218+
219+
/* Clickable lines */
220+
.code-panel :global(.line[data-step]) {
221+
cursor: pointer;
222+
}
223+
224+
/* Dimmed (when another step is selected) - lowest priority */
225+
.code-panel :global(.line-dimmed) {
226+
opacity: 0.4;
227+
transition: opacity 200ms ease;
228+
}
229+
230+
/* Ensure non-dimmed lines also transition smoothly */
231+
.code-panel :global(.line) {
232+
transition: opacity 200ms ease, background-color 200ms ease;
233+
}
234+
235+
/* Hover state - opaque blue highlight */
236+
.code-panel :global(.line-hovered) {
237+
background-color: rgba(88, 166, 255, 0.15) !important;
238+
opacity: 1 !important;
239+
}
240+
241+
/* Selected step (clicked by user) - stronger blue background */
242+
.code-panel :global(.line-selected) {
243+
background-color: rgba(88, 166, 255, 0.22) !important;
244+
opacity: 1 !important;
245+
}
246+
247+
/* Step status container */
248+
.step-status-container {
249+
position: absolute;
250+
right: 16px;
251+
transform: translateY(-50%);
252+
z-index: 10;
253+
pointer-events: none;
254+
transition: all 0.3s ease;
255+
display: flex;
256+
align-items: center;
257+
gap: 8px;
258+
}
259+
260+
/* Status labels */
261+
.status-label {
262+
font-size: 0.875rem;
263+
font-weight: 600;
264+
text-transform: lowercase;
265+
font-family: 'Fira Code', 'Monaco', 'Menlo', 'Courier New', monospace;
266+
line-height: 1;
267+
}
268+
269+
.status-completed {
270+
color: #20a56f;
271+
}
272+
273+
.status-started {
274+
color: #5b8def;
275+
}
276+
277+
.status-failed {
278+
color: #f08060;
279+
}
280+
281+
.status-created {
282+
color: #607b75;
283+
}
284+
</style>

0 commit comments

Comments
 (0)