From b556bb5ebf0380b7e442a7822c418a9c885c5051 Mon Sep 17 00:00:00 2001 From: Valery Bugakov Date: Wed, 5 Mar 2025 12:29:30 +0900 Subject: [PATCH] feat(auto-edit): full debug panel UI implementation (#7306) - Implements UI for the auto-edit debug panel based on the data we already have in store. I'll share a demo video in Slack later today. - Built on top of https://github.com/sourcegraph/cody/pull/7304 - There are several minor issues with the UI. E.g., context summary shows the total number of items as 0 while it's definitely not. The side-by-side diff view highlighting is inaccurate for some inline changes. We address such problems in follow-ups or keep them as is because this UI is for internal use, so we should not spend much extra time on it. --- .../src/autoedits/debug-panel/debug-store.ts | 4 +- vscode/webviews/autoedit-debug.tsx | 5 +- .../autoedit-debug/AutoeditDebugPanel.tsx | 93 +++++ .../autoedit-debug/autoedit-data-sdk.ts | 294 ++++++++++++++ .../autoedit-debug/autoedit-debug.css | 55 +++ .../autoedit-debug/autoedit-ui-utils.ts | 355 +++++++++++++++++ .../components/AutoeditDetailView.tsx | 212 ++++++++++ .../components/AutoeditListItem.tsx | 229 +++++++++++ .../autoedit-debug/components/EmptyState.tsx | 14 + .../autoedit-debug/components/JsonViewer.tsx | 66 ++++ .../components/SyntaxHighlighter.tsx | 52 +++ .../side-by-side-diff/SideBySideDiff.tsx | 91 +++++ .../components/side-by-side-diff/utils.ts | 365 ++++++++++++++++++ .../sections/AutoeditsConfigSection.tsx | 30 ++ .../sections/ContextInfoSection.tsx | 155 ++++++++ .../sections/DiscardInfoSection.tsx | 35 ++ .../sections/NetworkRequestSection.tsx | 92 +++++ .../autoedit-debug/sections/PromptSection.tsx | 254 ++++++++++++ .../sections/TimelineSection.tsx | 211 ++++++++++ 19 files changed, 2607 insertions(+), 5 deletions(-) create mode 100644 vscode/webviews/autoedit-debug/AutoeditDebugPanel.tsx create mode 100644 vscode/webviews/autoedit-debug/autoedit-data-sdk.ts create mode 100644 vscode/webviews/autoedit-debug/autoedit-debug.css create mode 100644 vscode/webviews/autoedit-debug/autoedit-ui-utils.ts create mode 100644 vscode/webviews/autoedit-debug/components/AutoeditDetailView.tsx create mode 100644 vscode/webviews/autoedit-debug/components/AutoeditListItem.tsx create mode 100644 vscode/webviews/autoedit-debug/components/EmptyState.tsx create mode 100644 vscode/webviews/autoedit-debug/components/JsonViewer.tsx create mode 100644 vscode/webviews/autoedit-debug/components/SyntaxHighlighter.tsx create mode 100644 vscode/webviews/autoedit-debug/components/side-by-side-diff/SideBySideDiff.tsx create mode 100644 vscode/webviews/autoedit-debug/components/side-by-side-diff/utils.ts create mode 100644 vscode/webviews/autoedit-debug/sections/AutoeditsConfigSection.tsx create mode 100644 vscode/webviews/autoedit-debug/sections/ContextInfoSection.tsx create mode 100644 vscode/webviews/autoedit-debug/sections/DiscardInfoSection.tsx create mode 100644 vscode/webviews/autoedit-debug/sections/NetworkRequestSection.tsx create mode 100644 vscode/webviews/autoedit-debug/sections/PromptSection.tsx create mode 100644 vscode/webviews/autoedit-debug/sections/TimelineSection.tsx diff --git a/vscode/src/autoedits/debug-panel/debug-store.ts b/vscode/src/autoedits/debug-panel/debug-store.ts index c8d47a8f5e82..6032b050b543 100644 --- a/vscode/src/autoedits/debug-panel/debug-store.ts +++ b/vscode/src/autoedits/debug-panel/debug-store.ts @@ -91,10 +91,8 @@ export class AutoeditDebugStore implements vscode.Disposable { } private calculateSideBySideDiff(state: AutoeditRequestState): DecorationInfo | undefined { - // TODO: remove @ts-ignore once all auto-edit debug panel changes are merged. return 'prediction' in state - ? // @ts-ignore - getDecorationInfo(state.codeToReplaceData.codeToRewrite, state.prediction, CHARACTER_REGEX) + ? getDecorationInfo(state.codeToReplaceData.codeToRewrite, state.prediction, CHARACTER_REGEX) : undefined } diff --git a/vscode/webviews/autoedit-debug.tsx b/vscode/webviews/autoedit-debug.tsx index 911896f398c2..2d4c8c92c408 100644 --- a/vscode/webviews/autoedit-debug.tsx +++ b/vscode/webviews/autoedit-debug.tsx @@ -1,3 +1,4 @@ +import './autoedit-debug/autoedit-debug.css' import { useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' @@ -7,6 +8,7 @@ import type { } from '../src/autoedits/debug-panel/debug-protocol' import type { AutoeditRequestDebugState } from '../src/autoedits/debug-panel/debug-store' +import { AutoeditDebugPanel } from './autoedit-debug/AutoeditDebugPanel' import { getVSCodeAPI } from './utils/VSCodeApi' /** @@ -84,8 +86,7 @@ function App() { return (
-

Auto-Edit Debug Panel

-

We have {entries.length} entries

+
) } diff --git a/vscode/webviews/autoedit-debug/AutoeditDebugPanel.tsx b/vscode/webviews/autoedit-debug/AutoeditDebugPanel.tsx new file mode 100644 index 000000000000..91bdb07cfcd6 --- /dev/null +++ b/vscode/webviews/autoedit-debug/AutoeditDebugPanel.tsx @@ -0,0 +1,93 @@ +import { type FC, useState } from 'react' + +import type { AutoeditRequestDebugState } from '../../src/autoedits/debug-panel/debug-store' + +import { AutoeditDetailView } from './components/AutoeditDetailView' +import { AutoeditListItem } from './components/AutoeditListItem' +import { EmptyState } from './components/EmptyState' + +export const AutoeditDebugPanel: FC<{ entries: AutoeditRequestDebugState[] }> = ({ entries }) => { + const [selectedEntryId, setSelectedEntryId] = useState( + entries.length > 0 ? entries[0].state.requestId : null + ) + + const selectedEntry = entries.find(entry => entry.state.requestId === selectedEntryId) || null + + // Handle entry selection + const handleEntrySelect = (entryId: string) => { + setSelectedEntryId(entryId) + } + + // Navigate to previous request + const handlePrevious = () => { + if (!selectedEntryId || entries.length <= 1) return + + const currentIndex = entries.findIndex(entry => entry.state.requestId === selectedEntryId) + if (currentIndex > 0) { + setSelectedEntryId(entries[currentIndex - 1].state.requestId) + } + } + + // Navigate to next request + const handleNext = () => { + if (!selectedEntryId || entries.length <= 1) return + + const currentIndex = entries.findIndex(entry => entry.state.requestId === selectedEntryId) + if (currentIndex < entries.length - 1) { + setSelectedEntryId(entries[currentIndex + 1].state.requestId) + } + } + + // Close the detail view + const handleClose = () => { + setSelectedEntryId(null) + } + + if (entries.length === 0) { + return + } + + // Render the entries list component to avoid duplication + const entriesList = ( +
+ {entries.map(entry => ( + + ))} +
+ ) + + // When no entry is selected, display the list at full width + if (!selectedEntry) { + return
{entriesList}
+ } + + // When an entry is selected, display the split view + return ( +
+ {/* List panel (left side) */} +
+ {entriesList} +
+ + {/* Detail panel (right side) */} +
+ e.state.requestId === selectedEntryId) > 0} + hasNext={ + entries.findIndex(e => e.state.requestId === selectedEntryId) < + entries.length - 1 + } + /> +
+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/autoedit-data-sdk.ts b/vscode/webviews/autoedit-debug/autoedit-data-sdk.ts new file mode 100644 index 000000000000..02bf8275f064 --- /dev/null +++ b/vscode/webviews/autoedit-debug/autoedit-data-sdk.ts @@ -0,0 +1,294 @@ +import type { AutoeditRequestDebugState } from '../../src/autoedits/debug-panel/debug-store' +import { DISCARD_REASONS, getDetailedTimingInfo } from './autoedit-ui-utils' + +export const extractAutoeditData = (entry: AutoeditRequestDebugState) => { + const phase = entry.state.phase + const discardReason = getDiscardReason(entry) + const filePath = getFilePath(entry) + const codeToRewrite = getCodeToRewrite(entry) + const prediction = getPrediction(entry) + const triggerKind = getTriggerKind(entry) + const positionInfo = getPositionInfo(entry) + const languageId = getLanguageId(entry) + const decorationStats = getDecorationStats(entry) + const model = getModel(entry) + const timing = getDetailedTimingInfo(entry) + const document = getDocument(entry) + const position = getPosition(entry) + const modelResponse = getModelResponse(entry) + + return { + phase, + discardReason, + filePath, + codeToRewrite, + prediction, + triggerKind, + positionInfo, + languageId, + decorationStats, + model, + timing, + document, + position, + modelResponse, + } +} + +/** + * Gets the start time of an autoedit request based on its state + */ +export const getStartTime = (entry: AutoeditRequestDebugState): number => { + const { state } = entry + if ('startedAt' in state) { + return state.startedAt + } + return entry.updatedAt +} + +/** + * Gets the document object from the entry + */ +export const getDocument = (entry: AutoeditRequestDebugState) => { + if ('document' in entry.state) { + return entry.state.document + } + return null +} + +/** + * Gets the position object from the entry + */ +export const getPosition = (entry: AutoeditRequestDebugState) => { + if ('position' in entry.state) { + return entry.state.position + } + return null +} + +/** + * Gets the model used for the request + */ +export const getModel = (entry: AutoeditRequestDebugState): string | null => { + if ('payload' in entry.state && 'model' in entry.state.payload) { + return entry.state.payload.model + } + return null +} + +/** + * Extracts the file path from an autoedit entry + */ +export const getFilePath = (entry: AutoeditRequestDebugState): string => { + if ('document' in entry.state && entry.state.document) { + // Access the URI property of the document which should contain the path + // Using optional chaining to safely access properties + const uri = entry.state.document.uri || entry.state.document.fileName + + if (uri) { + // Extract just the filename without the path + // Handle both string paths and URI objects + const fileName = uri.path.split('/').pop() || 'Unknown file' + return fileName + } + } + return 'Unknown file' +} + +/** + * Extracts code preview from an autoedit entry + */ +export const getCodeToRewrite = (entry: AutoeditRequestDebugState): string | undefined => { + if ('codeToReplaceData' in entry.state && 'codeToRewrite' in entry.state.codeToReplaceData) { + return entry.state.codeToReplaceData.codeToRewrite + } + return undefined +} + +/** + * Gets the trigger kind in a readable format + */ +export const getTriggerKind = (entry: AutoeditRequestDebugState): string => { + if ('payload' in entry.state && 'triggerKind' in entry.state.payload) { + const triggerMap: Record = { + 1: 'Automatic', + 2: 'Manual', + 3: 'Suggest Widget', + 4: 'Cursor', + } + return triggerMap[entry.state.payload.triggerKind] || 'Unknown' + } + return 'Unknown trigger' +} + +/** + * Gets position information from an autoedit entry + */ +export const getPositionInfo = (entry: AutoeditRequestDebugState): string => { + if ('position' in entry.state && entry.state.position) { + // Handle position object safely by extracting line and character + const line = entry.state.position.line !== undefined ? entry.state.position.line + 1 : '?' + const character = + entry.state.position.character !== undefined ? entry.state.position.character : '?' + return `Line ${line}:${character}` + } + return '' +} + +/** + * Gets discard reason if applicable + */ +export const getDiscardReason = (entry: AutoeditRequestDebugState): string | null => { + if ( + entry.state.phase === 'discarded' && + 'payload' in entry.state && + 'discardReason' in entry.state.payload + ) { + return ( + DISCARD_REASONS[entry.state.payload.discardReason] || + `Unknown (${entry.state.payload.discardReason})` + ) + } + return null +} + +/** + * Gets language ID if available + */ +export const getLanguageId = (entry: AutoeditRequestDebugState): string | null => { + if ('payload' in entry.state && 'languageId' in entry.state.payload) { + return entry.state.payload.languageId + } + return null +} + +/** + * Gets decoration stats if available + */ +export const getDecorationStats = (entry: AutoeditRequestDebugState): string | null => { + if ( + 'payload' in entry.state && + 'decorationStats' in entry.state.payload && + entry.state.payload.decorationStats + ) { + const stats = entry.state.payload.decorationStats + const addedLines = stats.addedLines || 0 + const modifiedLines = stats.modifiedLines || 0 + const removedLines = stats.removedLines || 0 + + const parts = [] + if (addedLines > 0) parts.push(`+${addedLines} lines`) + if (modifiedLines > 0) parts.push(`~${modifiedLines} lines`) + if (removedLines > 0) parts.push(`-${removedLines} lines`) + + return parts.length > 0 ? parts.join(', ') : null + } + return null +} + +/** + * Safely get the payload from the entry + */ +export const getPayload = (entry: AutoeditRequestDebugState) => { + if ('payload' in entry.state) { + return entry.state.payload + } + return null +} + +/** + * Get the request ID + */ +export const getRequestId = (entry: AutoeditRequestDebugState): string => { + return entry.state.requestId +} + +/** + * Format a trigger kind number into a readable string + */ +export const formatTriggerKind = (triggerKind?: number): string => { + if (!triggerKind) return 'Unknown' + + const triggerMap: Record = { + 1: 'Automatic', + 2: 'Manual', + 3: 'Suggest Widget', + 4: 'Cursor', + } + return triggerMap[triggerKind] || 'Unknown' +} + +/** + * Get the prediction text if available + */ +export const getPrediction = (entry: AutoeditRequestDebugState): string | null => { + if ('prediction' in entry.state && typeof entry.state.prediction === 'string') { + return entry.state.prediction.trim() + } + return null +} + +/** + * Extract network latency information from the entry state + */ +export const getNetworkLatencyInfo = ( + entry: AutoeditRequestDebugState +): { upstreamLatency?: number; gatewayLatency?: number } => { + const upstreamLatency = + entry.state.phase === 'started' + ? entry.state.payload.upstreamLatency + : 'payload' in entry.state && 'upstreamLatency' in entry.state.payload + ? entry.state.payload.upstreamLatency + : undefined + + const gatewayLatency = + entry.state.phase === 'started' + ? entry.state.payload.gatewayLatency + : 'payload' in entry.state && 'gatewayLatency' in entry.state.payload + ? entry.state.payload.gatewayLatency + : undefined + + return { upstreamLatency, gatewayLatency } +} + +/** + * Get the full response body from the model if available + */ +export const getFullResponseBody = (entry: AutoeditRequestDebugState): any | null => { + if ('modelResponse' in entry.state && entry.state.modelResponse?.responseBody) { + return entry.state.modelResponse.responseBody + } + return null +} + +/** + * Get the complete model response if available + */ +export const getModelResponse = (entry: AutoeditRequestDebugState): any | null => { + if ('modelResponse' in entry.state) { + return entry.state.modelResponse + } + return null +} + +export const AutoeditDataSDK = { + extractAutoeditData, + getStartTime, + getFilePath, + getCodeToRewrite, + getTriggerKind, + getPositionInfo, + getDiscardReason, + getLanguageId, + getDecorationStats, + getPayload, + getRequestId, + formatTriggerKind, + getPrediction, + getDocument, + getPosition, + getModel, + getNetworkLatencyInfo, + getFullResponseBody, + getModelResponse, +} diff --git a/vscode/webviews/autoedit-debug/autoedit-debug.css b/vscode/webviews/autoedit-debug/autoedit-debug.css new file mode 100644 index 000000000000..6d250df9e41b --- /dev/null +++ b/vscode/webviews/autoedit-debug/autoedit-debug.css @@ -0,0 +1,55 @@ +@import '@vscode/codicons/dist/codicon'; +@import url(../utils/highlight.css); +@import url(../components/shadcn/shadcn.css); +@import url(../themes/index.css); + +/* Import Tailwind directives */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* VS Code specific overrides for Tailwind */ +:root { + color-scheme: light dark; +} + +/* VS Code theme-specific styling */ +body[data-vscode-theme-kind="vscode-dark"] .tw-bg-white { + background-color: var(--vscode-sideBar-background) !important; +} + +body[data-vscode-theme-kind="vscode-dark"] .tw-text-gray-500, +body[data-vscode-theme-kind="vscode-dark"] .tw-text-gray-800 { + color: var(--vscode-sideBar-foreground) !important; +} + +/* Base font and styling from original */ +@font-face { + font-family: cody-icons; + font-display: block; + src: url('../../resources/cody-icons.woff') format('woff'); +} + +:root { + /* Our syntax highlighter expects a dark code background color, regardless of the VS Code color theme. */ + --code-background: #222222; + --code-foreground: #ffffff; +} + +#root { + height: 100%; + margin: 0 auto; + font-family: var(--vscode-font-family); + color: var(--vscode-sideBar-foreground); + background-color: var(--vscode-sideBar-background); + /* Override VS Code Webview Toolkit elements */ + --border-width: none; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background-color: var(--vscode-sideBar-background); +} diff --git a/vscode/webviews/autoedit-debug/autoedit-ui-utils.ts b/vscode/webviews/autoedit-debug/autoedit-ui-utils.ts new file mode 100644 index 000000000000..a08eb171bcd7 --- /dev/null +++ b/vscode/webviews/autoedit-debug/autoedit-ui-utils.ts @@ -0,0 +1,355 @@ +import type { Phase } from '../../src/autoedits/analytics-logger/types' +import type { AutoeditRequestDebugState } from '../../src/autoedits/debug-panel/debug-store' + +/** + * Map of discard reason codes to human-readable messages + */ +export const DISCARD_REASONS: Record = { + 1: 'Client Aborted', + 2: 'Empty Prediction', + 3: 'Prediction Equals Code to Rewrite', + 4: 'Recent Edits', + 5: 'Suffix Overlap', + 6: 'Empty Prediction After Inline Completion Extraction', + 7: 'No Active Editor', + 8: 'Conflicting Decoration With Edits', + 9: 'Not Enough Lines in Editor', +} + +/** + * Format timestamp as a readable date + */ +export const formatTime = (timestamp: number): string => { + const date = new Date(timestamp) + return date.toLocaleTimeString() +} + +/** + * Format latency as a readable duration with appropriate units (ms/s/m) + */ +export const formatLatency = (milliseconds: number | undefined): string => { + if (milliseconds === undefined) { + return 'unknown' + } + + // Format with appropriate unit based on size + if (milliseconds < 1) { + return '< 1ms' + } + if (milliseconds < 1000) { + return `${Math.round(milliseconds)}ms` + } + if (milliseconds < 60000) { + return `${(milliseconds / 1000).toFixed(1)}s` + } + const minutes = Math.floor(milliseconds / 60000) + const seconds = ((milliseconds % 60000) / 1000).toFixed(1) + return `${minutes}m ${seconds}s` +} + +/** + * Calculate time duration between two timestamps + */ +export const calculateDuration = (start: number | undefined, end: number | undefined): string => { + if (typeof start !== 'number' || typeof end !== 'number') { + return 'unknown' + } + return formatLatency(end - start) +} + +/** + * Get status badge color based on phase + */ +export const getStatusColor = (phase: Phase): string => { + switch (phase) { + case 'started': + return 'tw-bg-yellow-200 tw-text-yellow-800' + case 'contextLoaded': + return 'tw-bg-blue-200 tw-text-blue-800' + case 'loaded': + return 'tw-bg-indigo-200 tw-text-indigo-800' + case 'postProcessed': + return 'tw-bg-purple-200 tw-text-purple-800' + case 'suggested': + return 'tw-bg-fuchsia-200 tw-text-fuchsia-800' + case 'read': + return 'tw-bg-teal-200 tw-text-teal-800' + case 'accepted': + return 'tw-bg-green-200 tw-text-green-800' + case 'rejected': + return 'tw-bg-red-200 tw-text-red-800' + case 'discarded': + return 'tw-bg-gray-200 tw-text-gray-800' + default: + return 'tw-bg-gray-200 tw-text-gray-800' + } +} + +/** + * Extract all phase timing information from an autoedit entry + */ +export const extractPhaseInfo = (entry: AutoeditRequestDebugState) => { + const { state } = entry + const startTime = 'startedAt' in state ? state.startedAt : entry.updatedAt + + // Define all possible phase transitions in order with alternating color families for better visibility + const phases: Array<{ + name: string + time?: number + color: string + }> = [ + { name: 'Start', time: startTime, color: 'tw-bg-gray-500' }, + { + name: 'Context Loaded', + time: 'contextLoadedAt' in state ? state.contextLoadedAt : undefined, + color: 'tw-bg-amber-500', + }, + { + name: 'Loaded', + time: 'loadedAt' in state ? state.loadedAt : undefined, + color: 'tw-bg-blue-500', + }, + { + name: 'Post Processed', + time: 'postProcessedAt' in state ? state.postProcessedAt : undefined, + color: 'tw-bg-purple-500', + }, + { + name: 'Suggested', + time: 'suggestedAt' in state ? state.suggestedAt : undefined, + color: 'tw-bg-pink-500', + }, + { + name: 'Read', + time: 'readAt' in state ? state.readAt : undefined, + color: 'tw-bg-cyan-500', + }, + { + name: 'Accepted', + time: 'acceptedAt' in state ? state.acceptedAt : undefined, + color: 'tw-bg-green-500', + }, + { + name: 'Rejected', + time: 'rejectedAt' in state ? state.rejectedAt : undefined, + color: 'tw-bg-red-500', + }, + { + name: 'Discarded', + time: + 'discardedAt' in state + ? state.discardedAt + : entry.state.phase === 'discarded' + ? entry.updatedAt + : undefined, + color: 'tw-bg-rose-600', + }, + ] + + // Filter out phases that didn't occur + const validPhases = phases.filter(phase => phase.time !== undefined) + + // Sort phases by time + validPhases.sort((a, b) => (a.time || 0) - (b.time || 0)) + + return validPhases +} + +/** + * Create segments between phases for visualization + */ +export const createTimelineSegments = ( + phases: Array<{ name: string; time?: number; color: string }> +) => { + const segments: Array<{ + name: string + startTime: number + endTime: number + duration: number + color: string + startPhaseName: string + }> = [] + + // Create a segment between each consecutive phase + for (let i = 0; i < phases.length - 1; i++) { + const startPhase = phases[i] + const endPhase = phases[i + 1] + + segments.push({ + name: endPhase.name, + startPhaseName: startPhase.name, + startTime: startPhase.time || 0, + endTime: endPhase.time || 0, + duration: (endPhase.time || 0) - (startPhase.time || 0), + color: endPhase.color, + }) + } + + return segments +} + +/** + * Calculate logical widths for the timeline segments + */ +export const calculateTimelineWidths = (segments: Array<{ duration: number }>) => { + const totalDuration = segments.reduce((sum, segment) => sum + segment.duration, 0) + + // If the smallest segment is less than 5% of the total, use a minimum width approach + const MIN_WIDTH_PERCENT = 5 + const smallestSegmentPercentage = Math.min(...segments.map(s => (s.duration / totalDuration) * 100)) + + if (smallestSegmentPercentage < MIN_WIDTH_PERCENT) { + // Apply minimum width to small segments and distribute the rest proportionally + const smallSegments = segments.filter( + s => (s.duration / totalDuration) * 100 < MIN_WIDTH_PERCENT + ) + const smallSegmentsCount = smallSegments.length + + // Total percentage allocated to small segments + const smallSegmentsPercentage = MIN_WIDTH_PERCENT * smallSegmentsCount + + // Remaining percentage for normal segments + const remainingPercentage = 100 - smallSegmentsPercentage + + // Total duration of normal segments + const normalSegmentsDuration = segments + .filter(s => (s.duration / totalDuration) * 100 >= MIN_WIDTH_PERCENT) + .reduce((sum, s) => sum + s.duration, 0) + + return segments.map(segment => { + if ((segment.duration / totalDuration) * 100 < MIN_WIDTH_PERCENT) { + return MIN_WIDTH_PERCENT + } + return (segment.duration / normalSegmentsDuration) * remainingPercentage + }) + } + + // All segments are big enough, use proportional widths + return segments.map(segment => (segment.duration / totalDuration) * 100) +} + +/** + * Calculate the total duration up to a specific phase (or the end) + */ +export const calculateTotalDuration = ( + phases: Array<{ name: string; time?: number }>, + upToPhase?: string +) => { + if (phases.length < 1) { + return 0 + } + + const startTime = phases[0]?.time ?? 0 + + // If upToPhase is specified, find that phase + if (upToPhase) { + const targetPhase = phases.find(phase => phase.name === upToPhase) + if (targetPhase?.time) { + return targetPhase.time - startTime + } + } + + // Otherwise use the last phase + return phases.length > 1 ? (phases[phases.length - 1]?.time ?? 0) - startTime : 0 +} + +/** + * Get detailed timing information from an entry + * Returns an object with predictionDuration (time from start to suggested phase) and detailed timing breakdowns + */ +export const getDetailedTimingInfo = ( + entry: AutoeditRequestDebugState +): { + predictionDuration: string + details: Array<{ label: string; value: string }> +} => { + const result = { + predictionDuration: '', + details: [] as Array<{ label: string; value: string }>, + } + + // Calculate time from start to suggested phase (prediction duration) + // This matches the calculation in TimelineSection + const phases = extractPhaseInfo(entry) + const predictionDurationMs = calculateTotalDuration(phases, 'Suggested') + + if (predictionDurationMs > 0) { + result.predictionDuration = formatLatency(predictionDurationMs) + } else if ('payload' in entry.state && 'latency' in entry.state.payload) { + // Fallback to payload latency only if we couldn't calculate directly + result.predictionDuration = formatLatency(entry.state.payload.latency) + } else { + result.predictionDuration = 'unknown' + } + + // Add detailed timing breakdowns + const state = entry.state + const startTime = 'startedAt' in state ? state.startedAt : undefined + + if (startTime !== undefined) { + // Context loading time + if ('contextLoadedAt' in state) { + result.details.push({ + label: 'Context Loading', + value: calculateDuration(startTime, state.contextLoadedAt), + }) + } + + // Model generation time + if ('contextLoadedAt' in state && 'loadedAt' in state) { + result.details.push({ + label: 'Model Generation', + value: calculateDuration(state.contextLoadedAt, state.loadedAt), + }) + } + + // Post-processing time + if ('loadedAt' in state && 'postProcessedAt' in state) { + result.details.push({ + label: 'Post-processing', + value: calculateDuration(state.loadedAt, state.postProcessedAt), + }) + } + + // Time to suggest + if ('postProcessedAt' in state && 'suggestedAt' in state) { + result.details.push({ + label: 'Time to Suggest', + value: calculateDuration(state.postProcessedAt, state.suggestedAt), + }) + } + + // Gateway latency if available + if ('payload' in state && 'gatewayLatency' in state.payload && state.payload.gatewayLatency) { + result.details.push({ + label: 'Gateway Latency', + value: formatLatency(state.payload.gatewayLatency), + }) + } + + // Upstream latency if available + if ('payload' in state && 'upstreamLatency' in state.payload && state.payload.upstreamLatency) { + result.details.push({ + label: 'Upstream Latency', + value: formatLatency(state.payload.upstreamLatency), + }) + } + } + + return result +} + +/** + * Helper functions to generate keys for React components + */ +export const createSegmentKey = (segment: { + name: string + startTime: number + endTime: number +}): string => { + return `${segment.name}-${segment.startTime}-${segment.endTime}` +} + +export const createPhaseKey = (phase: { name: string; time?: number }): string => { + return `${phase.name}-${phase.time || 'undefined'}` +} diff --git a/vscode/webviews/autoedit-debug/components/AutoeditDetailView.tsx b/vscode/webviews/autoedit-debug/components/AutoeditDetailView.tsx new file mode 100644 index 000000000000..eeaae158329c --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/AutoeditDetailView.tsx @@ -0,0 +1,212 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs' +import { ChevronLeft, ChevronRight, X } from 'lucide-react' +import type { FC } from 'react' +import { useState } from 'react' + +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' +import { Badge } from '../../components/shadcn/ui/badge' +import { Button } from '../../components/shadcn/ui/button' + +import { AutoeditDataSDK } from '../autoedit-data-sdk' +import { getStatusColor } from '../autoedit-ui-utils' +import { AutoeditsConfigSection } from '../sections/AutoeditsConfigSection' +import { ContextInfoSection } from '../sections/ContextInfoSection' +import { NetworkRequestSection, NetworkResponseSection } from '../sections/NetworkRequestSection' +import { PromptSection } from '../sections/PromptSection' +import { TimelineSection } from '../sections/TimelineSection' +import { SideBySideDiff } from './side-by-side-diff/SideBySideDiff' + +export const AutoeditDetailView: FC<{ + entry: AutoeditRequestDebugState + onPrevious: () => void + onNext: () => void + onClose: () => void + hasPrevious: boolean + hasNext: boolean +}> = ({ entry, onPrevious, onNext, onClose, hasPrevious, hasNext }) => { + const [activeTab, setActiveTab] = useState('timeline') + + // Extract all relevant data in one place using the SDK + const { phase, filePath, discardReason, position, prediction, codeToRewrite, triggerKind } = + AutoeditDataSDK.extractAutoeditData(entry) + + return ( +
+ {/* Header with navigation */} +
+
+
+
+ {entry.state.requestId} +
+ {phase} + + {triggerKind} + +
+ +
+ + + +
+
+ +
+

+ {filePath} + {position ? `:${position?.line + 1}:${position?.character + 1}` : ''} +

+ + {/* Diff View Section */} + {entry.sideBySideDiffDecorationInfo && ( +
+ +
+ )} + + {/* Prediction text */} + {!entry.sideBySideDiffDecorationInfo && prediction && ( +
+

Prediction Text

+
+                                {prediction}
+                            
+
+ )} + + {/* Code to rewrite */} + {!entry.sideBySideDiffDecorationInfo && codeToRewrite && ( +
+

Code to Rewrite

+
+                                {codeToRewrite}
+                            
+
+ )} +
+
+ + {/* Discard warning if applicable */} + {discardReason && ( +
+
+ +

+ Request Discarded +

+
+

+ Reason: {discardReason} +

+
+ )} + + {/* Tabbed content */} + +
+ + + Timeline + + + Prompt + + + Context + + + Request + + + Response + + + Config + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} + +const TabButton: FC<{ value: string; activeTab: string; children: React.ReactNode }> = ({ + value, + activeTab, + children, +}) => { + const isActive = value === activeTab + return ( + + {children} + + ) +} diff --git a/vscode/webviews/autoedit-debug/components/AutoeditListItem.tsx b/vscode/webviews/autoedit-debug/components/AutoeditListItem.tsx new file mode 100644 index 000000000000..354cc0784806 --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/AutoeditListItem.tsx @@ -0,0 +1,229 @@ +import { X } from 'lucide-react' +import type { FC } from 'react' +import React from 'react' + +import type { Phase } from '../../../src/autoedits/analytics-logger/types' +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' +import { Badge } from '../../components/shadcn/ui/badge' +import { AutoeditDataSDK } from '../autoedit-data-sdk' +import { formatTime, type getDetailedTimingInfo, getStatusColor } from '../autoedit-ui-utils' + +// Sub-component for header section +const EntryHeader: FC<{ + phase: Phase + triggerKind: string + timingInfo: ReturnType + timestamp: number + onToggleDetailedTiming: () => void + showDetailedTiming: boolean +}> = ({ phase, triggerKind, timingInfo, timestamp, onToggleDetailedTiming, showDetailedTiming }) => ( +
+
+ {phase} + {triggerKind} +
+
+ + + {formatTime(timestamp)} + +
+
+) + +// Sub-component for detailed timing +const DetailedTiming: FC<{ + timingInfo: ReturnType +}> = ({ timingInfo }) => ( +
+
+ Detailed Timing +
+
+ {timingInfo.details.map(detail => ( + +
+ {detail.label}: +
+
+ {detail.value} +
+
+ ))} +
+
+) + +// Sub-component for file info +const FileInfo: FC<{ + filePath: string + positionInfo: string + languageId: string | null +}> = ({ filePath, positionInfo, languageId }) => ( +
+ {filePath} + {positionInfo && ( + <> + + {positionInfo} + + )} + {languageId && ( + <> + + {languageId} + + )} +
+) + +// Sub-component for code preview +const CodePreview: FC<{ + codeText: string + codeType: string + decorationStats: string | null + isCodeTruncated: boolean + onToggleTruncation: (e: React.MouseEvent) => void +}> = ({ codeText, codeType, decorationStats, isCodeTruncated, onToggleTruncation }) => { + const truncatedText = codeText.length > 80 ? codeText.substring(0, 80) + '...' : codeText + + return ( +
+
+ + {codeType === 'code-to-rewrite' + ? 'Code to Rewrite' + : codeType === 'prediction' + ? 'Prediction' + : 'No Code'} + +
+ {codeText.length > 80 && ( + + )} + {decorationStats && ( + + {decorationStats} + + )} +
+
+
+ {isCodeTruncated ? truncatedText : codeText} +
+
+ ) +} + +interface AutoeditEntryItemProps { + entry: AutoeditRequestDebugState + isSelected: boolean + onSelect: (entryId: string) => void +} + +// Main component +export const AutoeditListItem: FC = ({ entry, isSelected, onSelect }) => { + // Extract all data from entry using the SDK + const { + phase, + filePath, + codeToRewrite = '', + triggerKind, + positionInfo, + discardReason, + languageId, + decorationStats, + timing, + } = AutoeditDataSDK.extractAutoeditData(entry) + + // State management + const [showDetailedTiming, setShowDetailedTiming] = React.useState(false) + const [isCodeTruncated, setIsCodeTruncated] = React.useState(codeToRewrite.length > 200) + + // Calculate card classes based on selection state + const cardClasses = ` + tw-border tw-border-gray-200 tw-dark:tw-border-gray-700 + tw-rounded-md tw-mb-2 tw-overflow-hidden tw-cursor-pointer + tw-transition-colors tw-duration-150 + ${ + isSelected + ? 'tw-bg-blue-50 tw-dark:tw-bg-blue-900/20 tw-border-blue-300 tw-dark:tw-border-blue-700' + : 'tw-bg-white tw-dark:tw-bg-gray-800 hover:tw-bg-gray-50 dark:hover:tw-bg-gray-700/50' + } + ` + .trim() + .replace(/\s+/g, ' ') + + return ( +
onSelect(entry.state.requestId)} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + onSelect(entry.state.requestId) + e.preventDefault() + } + }} + > +
+
+ {/* Header with status, timestamp, and latency */} + setShowDetailedTiming(!showDetailedTiming)} + showDetailedTiming={showDetailedTiming} + /> + + {/* Detailed timing information (collapsible) */} + {showDetailedTiming && timing.details.length > 0 && ( + + )} + + {/* File and position info */} + + + {/* Code preview */} + { + e.stopPropagation() + setIsCodeTruncated(!isCodeTruncated) + }} + /> + + {/* Discard reason if applicable */} + {discardReason && ( +
+ + {discardReason} +
+ )} +
+
+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/components/EmptyState.tsx b/vscode/webviews/autoedit-debug/components/EmptyState.tsx new file mode 100644 index 000000000000..f94dc95d86e2 --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/EmptyState.tsx @@ -0,0 +1,14 @@ +import { FileText } from 'lucide-react' +import type { FC } from 'react' + +export const EmptyState: FC = () => { + return ( +
+ +

+ No auto-edit requests recorded yet. Start typing or moving your cursor to trigger + auto-edit. +

+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/components/JsonViewer.tsx b/vscode/webviews/autoedit-debug/components/JsonViewer.tsx new file mode 100644 index 000000000000..9b9c80048ebe --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/JsonViewer.tsx @@ -0,0 +1,66 @@ +import type { FC } from 'react' +import { useMemo } from 'react' +import 'highlight.js/styles/github.css' +import hljs from 'highlight.js/lib/core' +import { SYNTAX_HIGHLIGHTING_LANGUAGES } from '../../utils/highlight' + +import { SyntaxHighlighter } from './SyntaxHighlighter' + +// Ensure JSON language is registered for highlighting +hljs.registerLanguage('json', SYNTAX_HIGHLIGHTING_LANGUAGES.json) + +/** + * A hook that formats and highlights JSON data + * + * @param data The JSON data to format and highlight + * @returns The highlighted HTML string or null if the data is invalid + */ +const useJsonHighlighting = (data: any): string | null => { + return useMemo(() => { + if (data === null || data === undefined) return null + + try { + const jsonString = JSON.stringify(data, null, 2) + const result = hljs.highlight(jsonString, { language: 'json' }) + return result.value + } catch (error) { + console.error('Error highlighting JSON:', error) + return null + } + }, [data]) +} + +interface JsonViewerProps { + data: any + className?: string + maxHeight?: string + title?: string +} + +/** + * A reusable component for displaying JSON data with syntax highlighting + */ +export const JsonViewer: FC = ({ data, className = '', maxHeight = '80', title }) => { + const highlightedJson = useJsonHighlighting(data) + + if (data === null || data === undefined) { + return null + } + + return ( +
+ {title &&

{title}

} +
+
+                    {highlightedJson ? (
+                        
+                    ) : (
+                        JSON.stringify(data, null, 2)
+                    )}
+                
+
+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/components/SyntaxHighlighter.tsx b/vscode/webviews/autoedit-debug/components/SyntaxHighlighter.tsx new file mode 100644 index 000000000000..c212a41ab160 --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/SyntaxHighlighter.tsx @@ -0,0 +1,52 @@ +import type { FC } from 'react' + +interface SyntaxHighlighterProps { + highlightedCode: string + className?: string +} + +/** + * A component that safely encapsulates the dangerouslySetInnerHTML usage for syntax highlighting. + * This concentrates the linter warning to a single component rather than having it scattered + * throughout the codebase. + */ +export const SyntaxHighlighter: FC = ({ highlightedCode, className }) => { + // If there's no highlighted code, don't render anything + if (!highlightedCode) { + return null + } + + return ( + + ) +} + +/** + * Represents a line or segment of code that may have syntax highlighting. + */ +export interface HighlightedSegment { + text: string + highlightedHtml?: string + className?: string + isChange?: boolean // Flag to indicate if this is a change that should be prominently displayed +} + +/** + * Component to render a line or segment of code that may have syntax highlighting applied. + */ +export const CodeSegment: FC = ({ text, highlightedHtml, className, isChange }) => { + // Combine classes for consistent styling + const combinedClass = `${className || ''} ${isChange ? 'tw-font-semibold' : ''}`.trim() + + // If there's highlighted HTML, use it, otherwise just render the text + if (highlightedHtml) { + return + } + + // If no highlighting, just render the text directly + return {text} +} diff --git a/vscode/webviews/autoedit-debug/components/side-by-side-diff/SideBySideDiff.tsx b/vscode/webviews/autoedit-debug/components/side-by-side-diff/SideBySideDiff.tsx new file mode 100644 index 000000000000..b2414ccad7d1 --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/side-by-side-diff/SideBySideDiff.tsx @@ -0,0 +1,91 @@ +import type { FC } from 'react' +import 'highlight.js/styles/github.css' +import hljs from 'highlight.js/lib/core' + +import type { + DecorationInfo, + DecorationLineInfo, +} from '../../../../src/autoedits/renderer/decorators/base' + +import { SYNTAX_HIGHLIGHTING_LANGUAGES } from '../../../utils/highlight' +import { buildSideBySideLines } from './utils' + +for (const [name, language] of Object.entries(SYNTAX_HIGHLIGHTING_LANGUAGES)) { + hljs.registerLanguage(name, language) +} + +/** + * The main SideBySideDiff React component. + * Renders a two-column table of code lines (original on the left, modified on the right) + * with syntax highlighting and sub-line highlights for inserts/deletes. + * The container allows horizontal scrolling for long lines. + */ +export const SideBySideDiff: FC<{ + sideBySideDiffDecorationInfo: DecorationInfo + languageId: string +}> = ({ sideBySideDiffDecorationInfo, languageId }) => { + const sideBySideLines = buildSideBySideLines(sideBySideDiffDecorationInfo, languageId) + + return ( +
+ + + + + + + + + {sideBySideLines.map((line, index) => ( + + + + + ))} + +
+ Code To Rewrite + Prediction
+ {line.left.lineNumber ?? ''} + + + {line.right.lineNumber ?? ''} + +
+
+ ) +} + +/** + * Map a line type to a CSS class for the cell background. We use Tailwind classes here, + * but you can adapt these as needed. + */ +export function lineTypeToTdClass(type: DecorationLineInfo['type'] | 'empty') { + switch (type) { + case 'added': + return 'tw-bg-green-50' + case 'removed': + return 'tw-bg-red-50' + case 'modified': + // Entire line tinted lightly; sub-line changes use .tw-bg-green-200/.tw-bg-red-200 + return 'tw-bg-yellow-50' + default: + return '' + } +} diff --git a/vscode/webviews/autoedit-debug/components/side-by-side-diff/utils.ts b/vscode/webviews/autoedit-debug/components/side-by-side-diff/utils.ts new file mode 100644 index 000000000000..76f40090c2b9 --- /dev/null +++ b/vscode/webviews/autoedit-debug/components/side-by-side-diff/utils.ts @@ -0,0 +1,365 @@ +import type { + AddedLineInfo, + DecorationInfo, + DecorationLineInfo, + LineChange, + ModifiedLineInfo, + RemovedLineInfo, + UnchangedLineInfo, +} from '../../../../src/autoedits/renderer/decorators/base' +import 'highlight.js/styles/github.css' +import hljs from 'highlight.js/lib/core' + +import { SYNTAX_HIGHLIGHTING_LANGUAGES } from '../../../utils/highlight' + +for (const [name, language] of Object.entries(SYNTAX_HIGHLIGHTING_LANGUAGES)) { + hljs.registerLanguage(name, language) +} + +/** A side-by-side line, representing left (original) and right (modified). */ +export interface SideBySideColumn { + html: string | null + lineNumber: number | null + type: DecorationLineInfo['type'] | 'empty' +} + +export interface SideBySideLine { + left: SideBySideColumn + right: SideBySideColumn +} + +/** + * Produce an array of side-by-side lines from the DecorationInfo. Each line is + * highlighted with highlight.js, then sub-string changes are wrapped in + * or (as appropriate). + */ +export function buildSideBySideLines( + decorationInfo: DecorationInfo, + languageId: string +): SideBySideLine[] { + const { addedLines, removedLines, modifiedLines, unchangedLines } = decorationInfo + + // Collect all lines in one array, each with a "sortKey" so we can order them. + // Typically, we use originalLineNumber or modifiedLineNumber for sorting. + type UnifiedLine = + | (RemovedLineInfo & { sortKey: number }) + | (AddedLineInfo & { sortKey: number }) + | (ModifiedLineInfo & { sortKey: number }) + | (UnchangedLineInfo & { sortKey: number }) + + const aggregator: UnifiedLine[] = [] + + // Removed => sort by originalLineNumber + aggregator.push(...removedLines.map(l => ({ ...l, sortKey: l.originalLineNumber }))) + // Added => sort by modifiedLineNumber + aggregator.push(...addedLines.map(l => ({ ...l, sortKey: l.modifiedLineNumber }))) + // Unchanged => pick originalLineNumber for sorting + aggregator.push(...unchangedLines.map(l => ({ ...l, sortKey: l.originalLineNumber }))) + // Modified => pick originalLineNumber + aggregator.push(...modifiedLines.map(l => ({ ...l, sortKey: l.originalLineNumber }))) + + // Sort them in ascending order + aggregator.sort((a, b) => a.sortKey - b.sortKey) + + return aggregator.map(line => { + switch (line.type) { + case 'removed': + return buildRemovedSideBySide(line, languageId) + case 'added': + return buildAddedSideBySide(line, languageId) + case 'unchanged': + return buildUnchangedSideBySide(line, languageId) + case 'modified': + return buildModifiedSideBySide(line, languageId) + } + }) +} + +/** Build a SideBySideLine for a removed line (exists on the left side only). */ +export function buildRemovedSideBySide( + line: RemovedLineInfo & { sortKey: number }, + languageId: string +): SideBySideLine { + const text = line.text + const leftHl = highlightLine(text, languageId) + // No sub-line changes if line is purely removed + return { + left: { + html: leftHl, + lineNumber: line.originalLineNumber, + type: 'removed', + }, + right: { + html: '', + lineNumber: null, + type: 'empty', + }, + } +} + +/** Build a SideBySideLine for an added line (exists on the right side only). */ +export function buildAddedSideBySide( + line: AddedLineInfo & { sortKey: number }, + languageId: string +): SideBySideLine { + const text = line.text + const rightHl = highlightLine(text, languageId) + return { + left: { + html: '', + lineNumber: null, + type: 'empty', + }, + right: { + html: rightHl, + lineNumber: line.modifiedLineNumber, + type: 'added', + }, + } +} + +/** Unchanged line: identical text on both sides, no sub-line highlighting. */ +export function buildUnchangedSideBySide( + line: UnchangedLineInfo & { sortKey: number }, + languageId: string +): SideBySideLine { + const text = line.text + const hl = highlightLine(text, languageId) + return { + left: { + html: hl, + lineNumber: line.originalLineNumber, + type: 'unchanged', + }, + right: { + html: hl, + lineNumber: line.modifiedLineNumber, + type: 'unchanged', + }, + } +} + +/** + * Modified line: text has changed; we have oldText, newText, and sub-line changes + * (in line.changes). We must filter changes for the old text vs. new text. + * **Importantly** we must also handle the line-based offset if your line changes + * come in as file-wide offsets. + */ +export function buildModifiedSideBySide( + line: ModifiedLineInfo & { sortKey: number }, + languageId: string +): SideBySideLine { + const { oldText, newText, changes } = line + + // Filter/offset changes for the old side + const leftLineChanges = getChangesForLine(changes, line.originalLineNumber, 'original') + // Filter/offset changes for the new side + const rightLineChanges = getChangesForLine(changes, line.modifiedLineNumber, 'modified') + + const oldHl = highlightLine(oldText, languageId) + const newHl = highlightLine(newText, languageId) + + const decoratedLeft = decorateSyntaxHighlightedHTML(oldHl, leftLineChanges, 'original') + const decoratedRight = decorateSyntaxHighlightedHTML(newHl, rightLineChanges, 'modified') + + return { + left: { + html: decoratedLeft, + lineNumber: line.originalLineNumber, + type: 'modified', + }, + right: { + html: decoratedRight, + lineNumber: line.modifiedLineNumber, + type: 'modified', + }, + } +} + +/** + * Given a list of changes (which have file-wide ranges), extract only those relevant + * to the specified line. Then adjust their range so the character offsets are + * relative to the *start of that line* (0 = first character in that line). + * + * @param allChanges The entire array of sub-line changes from a ModifiedLineInfo + * @param lineNumber The line number we are rendering (0-based) + * @param whichSide 'original' or 'modified', determines whether to use originalRange or modifiedRange + */ +export function getChangesForLine( + allChanges: LineChange[], + lineNumber: number, + side: 'original' | 'modified' +): LineChange[] { + return allChanges + .map(change => { + const sideRange = side === 'original' ? change.originalRange : change.modifiedRange + // If it doesn't affect this line, skip it + if (sideRange.start.line > lineNumber || sideRange.end.line < lineNumber) { + return null + } + // If it spans multiple lines, clamp the range to this line. + // For single-line changes, it's enough to set: + const c = { ...change } + // Make a shallow copy so we don't mutate the original + const r = side === 'original' ? { ...c.originalRange } : { ...c.modifiedRange } + // Shift the line to 0-based for the substring + // @ts-ignore + r.start.character = Math.max(0, r.start.character) + // @ts-ignore + r.end.character = Math.max(r.start.character, r.end.character) + + if (side === 'original') { + // @ts-ignore + c.originalRange = r + } else { + // @ts-ignore + c.modifiedRange = r + } + + return c + }) + .filter(Boolean) as LineChange[] +} + +/** + * Takes a plain-text line, runs highlight.js to produce HTML tokens. + * If highlight.js doesn't support the language or fails, we fallback to escaping. + */ +export function highlightLine(text: string, languageId: string): string { + if (!text) return '' + try { + const result = hljs.highlight(text, { language: languageId }) + return result.value + } catch (e) { + return escapeHTML(text) + } +} + +/** + * Given highlight.js HTML for a single line, parse the DOM, walk text nodes, + * and wrap sub-string changes with or . + * Then serialize back to an HTML string. + * + * This must be called per line with the sub-line changes that apply *only* to that line, + * with character offsets relative to 0 at line start. + */ +export function decorateSyntaxHighlightedHTML( + hljsHtml: string, // The HTML string with syntax highlighting from highlight.js + changes: LineChange[], // Array of changes (inserts/deletes) that apply to this line + side: 'original' | 'modified' // Which side we're decorating: 'original' (left) or 'modified' (right) +): string { + // If no changes to apply, return the original HTML + if (!changes || changes.length === 0) { + return hljsHtml + } + + // Sort changes by the correct side's .start.character to process them in order + const sorted = [...changes].sort((a, b) => { + const aRange = side === 'original' ? a.originalRange : a.modifiedRange + const bRange = side === 'original' ? b.originalRange : b.modifiedRange + return aRange.start.character - bRange.start.character + }) + + // Parse the highlight.js HTML to manipulate the DOM + const parser = new DOMParser() + const doc = parser.parseFromString(`
${hljsHtml}
`, 'text/html') + + let globalOffset = 0 // Tracks the current character position in the overall text + let changeIndex = 0 // Tracks which change we're currently processing + let currentChange = sorted[0] || null // The current change being processed + + // Recursive function to walk the DOM nodes + function walk(node: ChildNode) { + // Only process text nodes directly; for element nodes, just process their children + if (node.nodeType !== Node.TEXT_NODE) { + const children = Array.from(node.childNodes) + + for (const child of children) { + walk(child) + } + + return + } + + const nodeText = node.textContent ?? '' // The text content of the current node + const nodeLength = nodeText.length // Length of the text content + const fragment = doc.createDocumentFragment() // Fragment to build the modified content + let consumed = 0 // Tracks how many characters we've processed in this text node + + // Process the text node until we've consumed all its text + while (consumed < nodeLength) { + // If we've processed all changes, just append the remaining text + if (!currentChange) { + // No more changes; append the remainder as plain text + const leftover = nodeText.slice(consumed) + fragment.appendChild(doc.createTextNode(leftover)) + globalOffset += leftover.length + consumed = nodeLength + break + } + + // Get the range for the current side (original or modified) + const sideRange = + side === 'original' ? currentChange.originalRange : currentChange.modifiedRange + const startOffset = sideRange.start.character // Where the change begins + const endOffset = sideRange.end.character // Where the change ends + + // If we've already passed this change entirely, move to the next change + if (globalOffset + consumed >= endOffset) { + changeIndex++ + currentChange = sorted[changeIndex] || null + continue + } + + // If we haven't yet reached the change's start position, add the unmodified text + if (globalOffset + consumed < startOffset) { + const sliceEnd = Math.min(startOffset - (globalOffset + consumed), nodeLength - consumed) + const textPortion = nodeText.slice(consumed, consumed + sliceEnd) + fragment.appendChild(doc.createTextNode(textPortion)) + consumed += textPortion.length + } else { + // We are inside the change range - add the changed text with highlighting + const sliceEnd = Math.min(endOffset - (globalOffset + consumed), nodeLength - consumed) + const changedPortion = nodeText.slice(consumed, consumed + sliceEnd) + consumed += sliceEnd + + // Create a span with appropriate background color for the changed text + const span = doc.createElement('span') + if (currentChange.type === 'insert' && side === 'modified') { + // Green background for insertions on the modified (right) side + span.classList.add('tw-bg-green-200') + } else if (currentChange.type === 'delete' && side === 'original') { + // Red background for deletions on the original (left) side + span.classList.add('tw-bg-red-200') + } + // If `unchanged` or not applicable side, no highlight + span.textContent = changedPortion + fragment.appendChild(span) + } + } + + globalOffset += consumed + // Replace the original node with our modified fragment + node.replaceWith(fragment) + } + + const children = Array.from(doc.childNodes) + // Process all child nodes in the document + for (const child of children) { + walk(child) + } + + // Return the updated HTML inside the wrapper div + const wrapper = doc.querySelector('div') + return wrapper ? wrapper.innerHTML : hljsHtml +} + +/** Safely escape HTML if highlight.js fails. */ +export function escapeHTML(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/vscode/webviews/autoedit-debug/sections/AutoeditsConfigSection.tsx b/vscode/webviews/autoedit-debug/sections/AutoeditsConfigSection.tsx new file mode 100644 index 000000000000..e839ac96f09d --- /dev/null +++ b/vscode/webviews/autoedit-debug/sections/AutoeditsConfigSection.tsx @@ -0,0 +1,30 @@ +import type { FC } from 'react' + +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' +import { JsonViewer } from '../components/JsonViewer' + +interface AutoeditsConfigSectionProps { + entry: AutoeditRequestDebugState +} + +/** + * A simple component to render the JSON configuration with styling. + */ +export const AutoeditsConfigSection: FC = ({ entry }) => { + const config = entry.autoeditsProviderConfig + + return ( +
+
+
+ + The configuration used for this autoedit request. + +
+
+ +
+
+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/sections/ContextInfoSection.tsx b/vscode/webviews/autoedit-debug/sections/ContextInfoSection.tsx new file mode 100644 index 000000000000..b627824f4ccf --- /dev/null +++ b/vscode/webviews/autoedit-debug/sections/ContextInfoSection.tsx @@ -0,0 +1,155 @@ +import type { FC } from 'react' + +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' + +export const ContextInfoSection: FC<{ entry: AutoeditRequestDebugState }> = ({ entry }) => { + if ( + !('payload' in entry.state) || + !('contextSummary' in entry.state.payload) || + !entry.state.payload.contextSummary + ) { + return null + } + + const contextSummary = entry.state.payload.contextSummary + + // Helper function to format duration + const formatDuration = (ms: number): string => { + return `${ms.toFixed(2)}ms` + } + + // Helper function to format character counts + const formatChars = (chars: number): string => { + return chars >= 1000 ? `${(chars / 1000).toFixed(1)}K` : chars.toString() + } + + return ( +
+ {/* Top-level overview */} +
+
+
+ {/* First column */} +
+ {'strategy' in contextSummary && ( +
+ Strategy: + {String(contextSummary.strategy)} +
+ )} + + {'duration' in contextSummary && ( +
+ Duration: + {formatDuration(contextSummary.duration)} +
+ )} + + {'totalChars' in contextSummary && ( +
+ Total Characters: + {formatChars(contextSummary.totalChars)} +
+ )} + + {'prefixChars' in contextSummary && ( +
+ Prefix Characters: + {formatChars(contextSummary.prefixChars)} +
+ )} +
+ + {/* Second column */} +
+ {'suffixChars' in contextSummary && ( +
+ Suffix Characters: + {formatChars(contextSummary.suffixChars)} +
+ )} + +
+ Context Items: + + {contextSummary && 'numContextItems' in contextSummary + ? String(contextSummary.numContextItems) + : '0'} + +
+ + {'snippetContextItems' in contextSummary && + contextSummary.snippetContextItems !== undefined && ( +
+ Snippet Items: + {String(contextSummary.snippetContextItems)} +
+ )} + + {'symbolContextItems' in contextSummary && + contextSummary.symbolContextItems !== undefined && ( +
+ Symbol Items: + {String(contextSummary.symbolContextItems)} +
+ )} + + {'localImportsContextItems' in contextSummary && ( +
+ Local Imports: + {String(contextSummary.localImportsContextItems)} +
+ )} +
+
+
+ + {/* Retriever Statistics Grid */} + {'retrieverStats' in contextSummary && + Object.keys(contextSummary.retrieverStats).length > 0 && ( +
+

Retriever Statistics

+
+ {/* Column Headers */} +
+
Retriever
+
Suggested Items
+
Retrieved Items
+
Characters
+
Duration
+
+ + {/* Retriever Rows */} + {Object.entries(contextSummary.retrieverStats).map( + ([identifier, stats], index) => ( +
+
{identifier}
+
+ {stats.suggestedItems} +
+
+ {stats.retrievedItems} +
+
+ {formatChars(stats.retrieverChars)} +
+
+ {formatDuration(stats.duration)} +
+
+ ) + )} +
+
+ )} +
+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/sections/DiscardInfoSection.tsx b/vscode/webviews/autoedit-debug/sections/DiscardInfoSection.tsx new file mode 100644 index 000000000000..87c59bdc21bb --- /dev/null +++ b/vscode/webviews/autoedit-debug/sections/DiscardInfoSection.tsx @@ -0,0 +1,35 @@ +import { X } from 'lucide-react' +import type { FC } from 'react' + +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' +import { DISCARD_REASONS } from '../autoedit-ui-utils' + +interface DiscardInfoSectionProps { + entry: AutoeditRequestDebugState +} + +export const DiscardInfoSection: FC = ({ entry }) => { + if ( + entry.state.phase !== 'discarded' || + !('payload' in entry.state) || + !('discardReason' in entry.state.payload) + ) { + return null + } + + return ( +
+
+ +

+ Request Discarded +

+
+

+ Reason:{' '} + {DISCARD_REASONS[entry.state.payload.discardReason] || + `Unknown (${entry.state.payload.discardReason})`} +

+
+ ) +} diff --git a/vscode/webviews/autoedit-debug/sections/NetworkRequestSection.tsx b/vscode/webviews/autoedit-debug/sections/NetworkRequestSection.tsx new file mode 100644 index 000000000000..dbc8b26cffd4 --- /dev/null +++ b/vscode/webviews/autoedit-debug/sections/NetworkRequestSection.tsx @@ -0,0 +1,92 @@ +import type { FC } from 'react' + +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' +import { JsonViewer } from '../components/JsonViewer' + +export const NetworkRequestSection: FC<{ + entry: AutoeditRequestDebugState +}> = ({ entry }) => { + if (!('payload' in entry.state)) { + return null + } + + // Extract modelResponse if available + const modelResponse = 'modelResponse' in entry.state ? entry.state.modelResponse : null + + return ( +
+ {/* Display request URL if available */} + {modelResponse?.requestUrl && ( +
+

Request URL

+
+ {modelResponse.requestUrl} +
+
+ )} + + {/* Display request headers if available */} + {modelResponse?.requestHeaders && ( +
+

Request Headers

+
+ {Object.entries(modelResponse.requestHeaders).map(([key, value]) => ( +
+ {key}:{' '} + {key.toLowerCase() === 'authorization' ? '[REDACTED]' : value} +
+ ))} +
+
+ )} + + {/* Display request body if available */} + {modelResponse?.requestBody && ( +
+ +
+ )} +
+ ) +} + +export const NetworkResponseSection: FC<{ + entry: AutoeditRequestDebugState +}> = ({ entry }) => { + if (!('payload' in entry.state)) { + return null + } + + // Extract modelResponse if available + const modelResponse = 'modelResponse' in entry.state ? entry.state.modelResponse : null + + return ( +
+ {/* Display response headers from modelResponse if available */} + {modelResponse?.responseHeaders && ( +
+

Response Headers

+
+ {Object.entries(modelResponse.responseHeaders).map(([key, value]) => ( +
+ {key}:{' '} + {key.toLowerCase() === 'authorization' ? '[REDACTED]' : value} +
+ ))} +
+
+ )} + + {/* Display full response body if available */} + {modelResponse?.responseBody && ( +
+ +
+ )} +
+ ) +} diff --git a/vscode/webviews/autoedit-debug/sections/PromptSection.tsx b/vscode/webviews/autoedit-debug/sections/PromptSection.tsx new file mode 100644 index 000000000000..f967f778b10c --- /dev/null +++ b/vscode/webviews/autoedit-debug/sections/PromptSection.tsx @@ -0,0 +1,254 @@ +import type { FC } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { Message, SerializedChatMessage } from '@sourcegraph/cody-shared' +import type { FireworksChatMessage } from '../../../src/autoedits/adapters/utils' +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' +import { getModelResponse } from '../autoedit-data-sdk' + +// Use a union type of the existing message types from the codebase +type MessageType = Message | SerializedChatMessage | FireworksChatMessage + +export const PromptSection: FC<{ entry: AutoeditRequestDebugState }> = ({ entry }) => { + // State to track whether the prompt is shown in fullscreen modal + const [isModalOpen, setIsModalOpen] = useState(false) + const [copySuccess, setCopySuccess] = useState(false) + const promptTextRef = useRef(null) + + // Get model response if available + const modelResponse = getModelResponse(entry) + + // Extract prompt data from request body + const requestBody = modelResponse?.requestBody + + // Format the prompt content + const formattedPrompt = formatPrompt() + + // Close modal with Escape key + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape' && isModalOpen) { + setIsModalOpen(false) + } + }, + [isModalOpen] + ) + + // Add and remove event listener + useEffect(() => { + if (isModalOpen) { + document.addEventListener('keydown', handleKeyDown) + // Prevent scrolling of the background when modal is open + document.body.style.overflow = 'hidden' + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [isModalOpen, handleKeyDown]) + + // Handle copying prompt text + const handleCopyPrompt = () => { + if (formattedPrompt) { + navigator.clipboard + .writeText(formattedPrompt) + .then(() => { + setCopySuccess(true) + setTimeout(() => setCopySuccess(false), 2000) + }) + .catch(err => console.error('Failed to copy text: ', err)) + } + } + + // Format the prompt based on its type + function formatPrompt(): string { + if (!requestBody) { + return 'No prompt data available' + } + + try { + // Handle messages array (both Fireworks and Sourcegraph formats) + if ('messages' in requestBody && Array.isArray(requestBody.messages)) { + return requestBody.messages + .map((message: MessageType) => { + // Handle Fireworks format (role/content) - used in FireworksChatModelRequestParams + if ('role' in message && typeof message.role === 'string') { + const role = message.role + const content = + 'content' in message && message.content !== undefined + ? String(message.content) + : 'No content' + return `${role}: ${content}` + } + + // Handle Sourcegraph format (speaker/text) - used in Message and SerializedChatMessage + if ('speaker' in message && typeof message.speaker === 'string') { + const speaker = message.speaker + const text = + 'text' in message && message.text !== undefined + ? String(message.text) + : 'No content' + return `${speaker}: ${text}` + } + + // Fallback for unknown message format + return JSON.stringify(message) + }) + .join('\n\n') + } + + // Handle single prompt (completion model) + if ('prompt' in requestBody && requestBody.prompt) { + return String(requestBody.prompt) + } + + // Try to extract data from any field that might contain the prompt + const possiblePromptFields = ['text', 'content', 'userMessage', 'input'] + for (const field of possiblePromptFields) { + if ( + field in requestBody && + requestBody[field] !== undefined && + requestBody[field] !== null + ) { + return String(requestBody[field]) + } + } + + // Handle nested prompt structures + if ('body' in requestBody && requestBody.body) { + if (typeof requestBody.body === 'string') { + return requestBody.body + } + + // Check for content in body + if ( + typeof requestBody.body === 'object' && + requestBody.body !== null && + 'content' in requestBody.body + ) { + return String(requestBody.body.content) + } + } + + // For other request bodies, convert to a simple readable format + return Object.entries(requestBody) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + return `${key}: [Object]` + } + return `${key}: ${value}` + }) + .join('\n') + } catch (error) { + console.error('Error formatting prompt:', error) + return 'Error formatting prompt. See console for details.' + } + } + + // Don't render anything if there's no prompt data + if (!modelResponse?.requestBody) { + return null + } + + // CSS classes for prompt text - always using full height now + const promptTextClass = + 'tw-bg-gray-50 tw-dark:tw-bg-gray-800 tw-p-4 tw-rounded tw-text-xs tw-font-mono tw-border-0 tw-m-0 tw-text-gray-800 dark:tw-text-gray-200 tw-leading-relaxed tw-whitespace-pre-wrap tw-h-full tw-overflow-y-auto' + + return ( + <> + {/* Prompt display section */} +
+
+
+ {/* Copy button */} + + + {/* Fullscreen button */} + +
+
+ + {/* Prompt content with enhanced styling using CSS classes instead of inline styles */} +
+
+                        {formattedPrompt}
+                    
+
+
+ + {/* Fullscreen modal with improved UX */} + {isModalOpen && ( +
+
+ {/* Modal header */} +
+ + +
+ {/* Copy button in modal */} + + + {/* Close button */} + +
+
+ + {/* Modal body */} +
+
+
+                                    {formattedPrompt}
+                                
+
+
+
+
+ )} + + ) +} diff --git a/vscode/webviews/autoedit-debug/sections/TimelineSection.tsx b/vscode/webviews/autoedit-debug/sections/TimelineSection.tsx new file mode 100644 index 000000000000..4070e807373a --- /dev/null +++ b/vscode/webviews/autoedit-debug/sections/TimelineSection.tsx @@ -0,0 +1,211 @@ +import { HelpCircle } from 'lucide-react' +import { type FC, useState } from 'react' + +import type { AutoeditRequestDebugState } from '../../../src/autoedits/debug-panel/debug-store' + +import { getNetworkLatencyInfo } from '../autoedit-data-sdk' +import { + calculateTimelineWidths, + calculateTotalDuration, + createPhaseKey, + createSegmentKey, + createTimelineSegments, + extractPhaseInfo, + formatLatency, +} from '../autoedit-ui-utils' + +interface TimelineSectionProps { + entry: AutoeditRequestDebugState +} + +export const TimelineSection: FC = ({ entry }) => { + // Add state to control tooltip visibility + const [isTooltipVisible, setIsTooltipVisible] = useState(false) + + // Extract phase information using shared utility + const phases = extractPhaseInfo(entry) + + // Create segments between phases using shared utility + const segments = createTimelineSegments(phases) + + // Calculate segment widths for visualization using shared utility + const segmentWidths = calculateTimelineWidths(segments) + + // Calculate total duration up to and including the suggested phase if available + const totalPredictionDuration = calculateTotalDuration(phases, 'Suggested') + + // Calculate total duration across all phases + const totalDuration = calculateTotalDuration(phases) + + // Extract network latency information using the SDK + const { upstreamLatency, gatewayLatency } = getNetworkLatencyInfo(entry) + + return ( +
+ {/* Summary of total prediction duration */} + {totalPredictionDuration > 0 && ( +
+ + Total suggestion latency:{' '} + {formatLatency(totalPredictionDuration)} + +
+ setIsTooltipVisible(true)} + onMouseLeave={() => setIsTooltipVisible(false)} + /> + {isTooltipVisible && ( +
+ Total prediction duration is the time from start until the prediction was + suggested to the user (including the suggested phase). Post-suggestion + phases (read, accepted, rejected) are not included. +
+ )} +
+
+ )} + + {/* Visual Timeline */} + {segments.length > 0 && ( +
+ {/* Timeline bar visualization */} +
+ {segments.map((segment, index) => ( +
+ + {formatLatency(segment.duration)} + +
+ ))} +
+ + {/* Timeline scale marker */} +
+ Start + Total: {formatLatency(totalDuration)} +
+
+ )} + + {/* Detailed Phase Timestamps */} +
+

Phase Details

+
+ {/* Column Headers */} +
+
{/* Empty column for color indicator */} +
Phase
+
From Start
+
Phase Duration
+
+ + {/* Phase Rows */} + {phases.map((phase, index) => { + // Calculate phase duration + const phaseDuration = + index > 0 ? (phase.time || 0) - (phases[index - 1].time || 0) : 0 + + return ( +
0 + ? 'tw-border-t tw-border-gray-200 tw-dark:tw-border-gray-700' + : '' + } ${index % 2 === 0 ? 'tw-bg-gray-50 tw-dark:tw-bg-gray-800/50' : ''}`} + > + {/* Color indicator */} +
+
+
+ + {/* Phase name */} +
{phase.name}
+ + {/* From start time */} +
+ {index === 0 ? ( + '—' + ) : ( + + {formatLatency((phase?.time ?? 0) - (phases[0]?.time ?? 0))} + + )} +
+ + {/* Phase duration */} +
+ {index === 0 ? ( + '—' + ) : ( + + {formatLatency(phaseDuration)} + + )} +
+
+ ) + })} +
+
+ + {/* Network Latency Details */} + {(upstreamLatency !== undefined || gatewayLatency !== undefined) && ( +
+

Network Latency

+
+ {/* Column Headers */} +
+
{/* Empty column for alignment with color indicator */} +
Service
+
Round Trip Time
+
{/* Empty column to match Phase Duration */} +
+ + {/* Upstream Latency Row */} + {upstreamLatency !== undefined && ( +
+
+
+
+
Sourcegraph API
+
+ + {formatLatency(upstreamLatency)} + +
+
{/* Empty column for alignment */} +
+ )} + + {/* Gateway Latency Row */} + {gatewayLatency !== undefined && ( +
+
+
+
+
Cody Gateway
+
+ + {formatLatency(gatewayLatency)} + +
+
{/* Empty column to match Phase Duration */} +
+ )} +
+
+ )} +
+ ) +}