From 4dae7b5f4af4e56a73a0290a1455f8363be2d0ee Mon Sep 17 00:00:00 2001 From: Agung Maulana Date: Wed, 15 Jul 2026 09:21:29 +0700 Subject: [PATCH 1/2] feat(ai-changes): add AI edit gutter highlights and indicators across UI Adds AI edit tracking with gutter decorations in FileViewer, toggling between Git and AI diff modes. Introduces purple dot indicators for files with AI changes in the File Explorer and Git panel. Clicking on AI-modified files or AI entries scrolls to and highlights the relevant edit. AI change indicators clear upon staging, commit, or file edits. Refactors component props and UI to support AI edit index metadata throughout file and diff lists. --- .gitignore | 4 + .../tools/variants/classic/EditTool.svelte | 11 + .../tools/variants/classic/WriteTool.svelte | 11 + .../classic/components/FileHeader.svelte | 7 +- .../tools/variants/compact/EditTool.svelte | 14 +- .../tools/variants/compact/WriteTool.svelte | 13 +- .../compact/components/FileHeader.svelte | 7 +- .../compact/components/ToolRow.svelte | 7 + frontend/components/files/FileNode.svelte | 20 +- frontend/components/files/FileTree.svelte | 6 +- frontend/components/files/FileViewer.svelte | 384 ++++++++++++++++-- frontend/components/git/ChangesSection.svelte | 6 +- frontend/components/git/FileChangeItem.svelte | 23 +- .../workspace/panels/FilesPanel.svelte | 17 + .../workspace/panels/GitPanel.svelte | 19 +- frontend/stores/features/git-status.svelte.ts | 20 + frontend/utils/ai-changes.ts | 128 ++++++ frontend/utils/line-diff.ts | 4 + 18 files changed, 655 insertions(+), 46 deletions(-) create mode 100644 frontend/utils/ai-changes.ts diff --git a/.gitignore b/.gitignore index 77c7cee5..2e1c292a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ temp-videos/ /okegas /okegas (1) /okegas (2) + +# Geeto state files +.geeto +geeto* diff --git a/frontend/components/chat/tools/variants/classic/EditTool.svelte b/frontend/components/chat/tools/variants/classic/EditTool.svelte index f52f3909..efdbd9c8 100644 --- a/frontend/components/chat/tools/variants/classic/EditTool.svelte +++ b/frontend/components/chat/tools/variants/classic/EditTool.svelte @@ -1,6 +1,7 @@ diff --git a/frontend/components/chat/tools/variants/classic/WriteTool.svelte b/frontend/components/chat/tools/variants/classic/WriteTool.svelte index 0c708540..38352356 100644 --- a/frontend/components/chat/tools/variants/classic/WriteTool.svelte +++ b/frontend/components/chat/tools/variants/classic/WriteTool.svelte @@ -2,6 +2,7 @@ import type { ToolUseBlock, WriteInput } from '$shared/types/unified'; import { FileHeader, DiffBlock } from './components'; import TextMessage from '../../../formatters/TextMessage.svelte'; + import { addAiChange } from '$frontend/utils/ai-changes'; const { toolInput }: { toolInput: ToolUseBlock } = $props(); const input = $derived(toolInput.input as WriteInput); @@ -9,11 +10,21 @@ const filePath = $derived(input.filePath || ''); const fileName = $derived(filePath.split(/[/\\]/).pop() || filePath || 'unknown'); const content = $derived(input.content || ''); + + const hasResult = $derived(!!toolInput.result && !toolInput.result.isError); + let editIndex = $state(null); + + $effect(() => { + if (hasResult && filePath && content) { + editIndex = addAiChange(filePath, '', content); + } + }); diff --git a/frontend/components/chat/tools/variants/classic/components/FileHeader.svelte b/frontend/components/chat/tools/variants/classic/components/FileHeader.svelte index 237a46c0..ca143026 100644 --- a/frontend/components/chat/tools/variants/classic/components/FileHeader.svelte +++ b/frontend/components/chat/tools/variants/classic/components/FileHeader.svelte @@ -2,9 +2,13 @@ import Icon from '$frontend/components/common/display/Icon.svelte'; import { getFileIcon } from '$frontend/utils/file-icon-mappings'; import { revealFile } from '$frontend/stores/ui/file-peek.svelte'; + import { requestAiScrollReveal } from '$frontend/utils/ai-changes'; function handleClick() { revealFile(filePath); + if (editIndex !== undefined && editIndex !== null) { + requestAiScrollReveal(filePath, editIndex); + } } interface Props { @@ -13,9 +17,10 @@ iconColor?: string; badges?: Array<{ text: string; color: string }>; box?: boolean; + editIndex?: number | null; } - const { filePath, fileName, iconColor, badges = [], box = true }: Props = $props(); + const { filePath, fileName, iconColor, badges = [], box = true, editIndex = null }: Props = $props(); const displayFileName = $derived(fileName || filePath.split(/[/\\]/).pop() || filePath); diff --git a/frontend/components/chat/tools/variants/compact/EditTool.svelte b/frontend/components/chat/tools/variants/compact/EditTool.svelte index c6207fee..a6921b75 100644 --- a/frontend/components/chat/tools/variants/compact/EditTool.svelte +++ b/frontend/components/chat/tools/variants/compact/EditTool.svelte @@ -2,6 +2,7 @@ import type { ToolUseBlock, EditInput } from '$shared/types/unified'; import { countLineChanges } from '$shared/utils/diff-calculator'; import { ToolRow } from './components'; + import { addAiChange } from '$frontend/utils/ai-changes'; const { toolInput }: { toolInput: ToolUseBlock } = $props(); const input = $derived(toolInput.input as EditInput); @@ -12,7 +13,18 @@ // Count only lines that actually changed (LCS-based), so identical context // lines inside the edited region aren't miscounted as +/-. const diff = $derived(countLineChanges(input.oldString || '', input.newString || '')); + + const hasResult = $derived(!!toolInput.result && !toolInput.result.isError); + const oldString = $derived(input.oldString || ''); + const newString = $derived(input.newString || ''); + let editIndex = $state(null); + + $effect(() => { + if (hasResult && filePath) { + editIndex = addAiChange(filePath, oldString, newString); + } + }); - + diff --git a/frontend/components/chat/tools/variants/compact/WriteTool.svelte b/frontend/components/chat/tools/variants/compact/WriteTool.svelte index 5affde22..7eceb17c 100644 --- a/frontend/components/chat/tools/variants/compact/WriteTool.svelte +++ b/frontend/components/chat/tools/variants/compact/WriteTool.svelte @@ -1,6 +1,7 @@ - + diff --git a/frontend/components/chat/tools/variants/compact/components/FileHeader.svelte b/frontend/components/chat/tools/variants/compact/components/FileHeader.svelte index ac96cf31..910aba5f 100644 --- a/frontend/components/chat/tools/variants/compact/components/FileHeader.svelte +++ b/frontend/components/chat/tools/variants/compact/components/FileHeader.svelte @@ -1,19 +1,24 @@ diff --git a/frontend/components/chat/tools/variants/compact/components/ToolRow.svelte b/frontend/components/chat/tools/variants/compact/components/ToolRow.svelte index c922b536..ec07c557 100644 --- a/frontend/components/chat/tools/variants/compact/components/ToolRow.svelte +++ b/frontend/components/chat/tools/variants/compact/components/ToolRow.svelte @@ -3,6 +3,7 @@ import { getFileIcon } from '$frontend/utils/file-icon-mappings'; import { getFolderIcon } from '$frontend/utils/folder-icon-mappings'; import { revealFile } from '$frontend/stores/ui/file-peek.svelte'; + import { requestAiScrollReveal } from '$frontend/utils/ai-changes'; import type { IconName } from '$shared/types/ui/icons'; interface DiffStat { @@ -37,6 +38,8 @@ expanded?: boolean; /** Called when header row is clicked (for expandable rows) */ onclick?: () => void; + /** AI edit index for scroll-reveal targeting */ + editIndex?: number | null; } let { @@ -53,6 +56,7 @@ expandable = false, expanded = $bindable(false), onclick, + editIndex = null, }: Props = $props(); const displayName = $derived(fileName || (filePath ? filePath.split(/[/\\]/).pop() || filePath : '')); @@ -68,6 +72,9 @@ e.stopPropagation(); if (!filePath) return; revealFile(filePath); + if (editIndex !== null) { + requestAiScrollReveal(filePath, editIndex); + } } function handleRowClick() { diff --git a/frontend/components/files/FileNode.svelte b/frontend/components/files/FileNode.svelte index a5f19a90..55c70bd9 100644 --- a/frontend/components/files/FileNode.svelte +++ b/frontend/components/files/FileNode.svelte @@ -36,7 +36,8 @@ onNodeDrop, onNodeDragEnd, dropTargetPath = null, - busyPaths = new Set() + busyPaths = new Set(), + aiChangesSet = new Set() }: { file: FileNodeType; isSelected?: boolean; @@ -63,6 +64,7 @@ onNodeDragEnd?: (file: FileNodeType, event: DragEvent) => void; dropTargetPath?: string | null; busyPaths?: Set; + aiChangesSet?: Set; } = $props(); const revealLabel = $derived( @@ -119,6 +121,11 @@ gitStatusCode ? getGitStatusBadgeLabel(gitStatusCode) : '' ); + // AI changes indicator + const hasAiChanges = $derived( + file.type === 'file' && aiChangesSet.has(file.path) + ); + let nodeElement: HTMLDivElement; let menuButtonElement: HTMLButtonElement; let menuStyle = $state(''); @@ -258,8 +265,8 @@ {file.name} - - {#if showModifiedIndicator || gitStatusCode} + + {#if showModifiedIndicator || hasAiChanges || gitStatusCode} {#if showModifiedIndicator} {/if} + {#if hasAiChanges} + + {/if} {#if gitStatusCode} {/each} {/if} diff --git a/frontend/components/files/FileTree.svelte b/frontend/components/files/FileTree.svelte index 18c2e926..bcc982c2 100644 --- a/frontend/components/files/FileTree.svelte +++ b/frontend/components/files/FileTree.svelte @@ -66,6 +66,8 @@ busyPaths?: Set; /** Root-level operation in progress (e.g. upload to project root). */ isRootBusy?: boolean; + /** File paths that have pending AI changes (for dot indicator). */ + aiChangesSet?: Set; } let { @@ -100,7 +102,8 @@ onClearSelection, isRootDropTarget = false, busyPaths = new Set(), - isRootBusy = false + isRootBusy = false, + aiChangesSet = new Set() }: Props = $props(); // Create local state if expandedFolders is not provided @@ -839,6 +842,7 @@ {onNodeDragEnd} {dropTargetPath} {busyPaths} + {aiChangesSet} /> {/each} diff --git a/frontend/components/files/FileViewer.svelte b/frontend/components/files/FileViewer.svelte index ec81f85a..69d1a295 100644 --- a/frontend/components/files/FileViewer.svelte +++ b/frontend/components/files/FileViewer.svelte @@ -11,7 +11,7 @@ import { getFolderIcon } from '$frontend/utils/folder-icon-mappings'; import { isImageFile, isSvgFile, isPdfFile, isAudioFile, isVideoFile, isBinaryFile, isBinaryContent, isPreviewableFile, isEditableImageFile } from '$frontend/utils/file-type'; import { formatFileSize } from '$frontend/utils/format'; - import { onMount } from 'svelte'; + import { onMount, untrack } from 'svelte'; import type { IconName } from '$shared/types/ui/icons'; import type { editor } from 'monaco-editor'; import { debug } from '$shared/utils/logger'; @@ -20,6 +20,8 @@ import { gitStatusState } from '$frontend/stores/features/git-status.svelte'; import { settings } from '$frontend/stores/features/settings.svelte'; import { revealFile } from '$frontend/stores/ui/file-peek.svelte'; + import { getAiChanges, clearAiChange, onAiChange, onAiScrollReveal, consumeAiScrollReveal, getGutterViewMode, setGutterViewMode, onGutterViewModeChange } from '$frontend/utils/ai-changes'; + import type { GutterViewMode } from '$frontend/utils/ai-changes'; // Interface untuk MonacoCodeEditor component interface MonacoEditorComponent { @@ -150,9 +152,15 @@ // Git gutter decorations + HEAD content cache let gutterDecorations: string[] = []; + let aiChangeDecorations: string[] = []; let envDecorations: string[] = []; let envDecoEditor: editor.IStandaloneCodeEditor | null = null; let gutterChanges: GutterChange[] = []; + let aiGutterChanges: GutterChange[] = []; + let activeRevealEditIdx = $state(null); + let lastRevealEditIdx = $state(null); + let hasAiChanges = $state(false); + let gutterMode = $state(getGutterViewMode()); let headContent = $state(null); let headContentForPath = ''; let pendingScrollRestore: number | null = null; @@ -162,6 +170,7 @@ let activeDiffZone: { id: string; line: number; + isAi: boolean; escHandler: (e: KeyboardEvent) => void; domNode: HTMLElement; overlayWidget: editor.IOverlayWidget; @@ -360,11 +369,19 @@ if (!path || !projectId) { headContent = null; headContentForPath = ''; + activeRevealEditIdx = null; + lastRevealEditIdx = null; + setGutterViewMode('git'); + closeDiffPeek(); return; } if (path === headContentForPath) return; headContentForPath = path; headContent = null; + activeRevealEditIdx = null; + lastRevealEditIdx = null; + setGutterViewMode('git'); + closeDiffPeek(); // Only fetch when git is tracking this file if (!gitStatusState.isRepo) return; @@ -400,6 +417,169 @@ } }); + // React to gutter view mode changes (AI vs Git) + $effect(() => { + const mode = gutterMode; + const editor = monacoEditorRef?.getEditor(); + if (!editor) return; + if (mode === 'ai') { + untrack(() => { + if (activeRevealEditIdx === null && lastRevealEditIdx === null) { + const allChanges = getAiChanges(file?.path || ''); + if (allChanges.length > 0) { + activeRevealEditIdx = allChanges.length - 1; + lastRevealEditIdx = allChanges.length - 1; + } + } + }); + applyAiChangeDecorations(); // re-apply AI gutter + updateGutterDecorations(true); // clear git gutter + } else { + applyAiChangeDecorations(true); // clear AI gutter + scheduleGutterUpdate(); + if (activeDiffZone && activeDiffZone.isAi) { + closeDiffPeek(); + } + } + }); + + // Sync with external preference changes + onMount(() => { + const unsub = onGutterViewModeChange((mode) => { + gutterMode = mode; + }); + return unsub; + }); + + // AI change decorations — highlight lines modified by AI (Write/Edit tools) + function applyAiChangeDecorations(forceClear = false) { + const editor = monacoEditorRef?.getEditor(); + if (!editor) return; + + const path = file?.path || ''; + if (!path) { + aiChangeDecorations = editor.deltaDecorations(aiChangeDecorations, []); + hasAiChanges = false; + return; + } + + const allChanges = getAiChanges(path); + hasAiChanges = allChanges.length > 0; + + // Capture scroll-reveal edit index first so we can filter by it on this pass + const revealEditIdx = consumeAiScrollReveal(path); + if (revealEditIdx >= 0) { + activeRevealEditIdx = revealEditIdx; + lastRevealEditIdx = revealEditIdx; + setGutterViewMode('ai'); + } + + if (forceClear || gutterMode === 'git') { + aiChangeDecorations = editor.deltaDecorations(aiChangeDecorations, []); + aiGutterChanges = []; + return; + } + + if (allChanges.length === 0) { + aiChangeDecorations = editor.deltaDecorations(aiChangeDecorations, []); + return; + } + + const merged: Array = []; + + for (let editIdx = 0; editIdx < allChanges.length; editIdx++) { + if (activeRevealEditIdx !== null && editIdx !== activeRevealEditIdx) continue; + + const change = allChanges[editIdx]; + const rawChanges = change.oldContent + ? computeLineDiff(change.oldContent, change.newContent) + : computeLineDiff('', change.newContent); + + let local: GutterChange[]; + if (!change.oldContent) { + local = rawChanges; + } else { + const idx = editableContent.indexOf(change.newContent); + if (idx >= 0) { + const beforeContent = editableContent.substring(0, idx); + const startLine = beforeContent.split('\n').length; + local = rawChanges.map((c) => ({ + ...c, + startLine: c.startLine + startLine - 1, + endLine: c.endLine + startLine - 1 + })); + } else { + local = []; + } + } + + for (const gc of local) { + merged.push({ ...gc, _editIdx: editIdx, _timestamp: change.timestamp }); + } + } + + const newDecorations = merged.map((entry) => { + const { _editIdx, _timestamp, ...change } = entry; + + const options: any = { + isWholeLine: false, + linesDecorationsClassName: `ai-gutter-${change.type}`, + overviewRuler: { + color: colorForChangeType(change.type), + position: OVERVIEW_RULER_RIGHT + } + }; + + return { + range: { + startLineNumber: change.startLine, + startColumn: 1, + endLineNumber: change.endLine, + endColumn: 1 + }, + options + }; + }); + + // Store tagged changes for peek + aiGutterChanges = merged.map(({ _editIdx, _timestamp, ...change }) => ({ + ...change, + editIndex: _editIdx, + timestamp: _timestamp, + })); + aiChangeDecorations = editor.deltaDecorations(aiChangeDecorations, newDecorations); + + // Scroll-reveal: focus on the first line of the specific edit the user clicked + if (revealEditIdx >= 0 && aiGutterChanges.length > 0) { + const targetChange = aiGutterChanges.find((g) => g.editIndex === revealEditIdx) + ?? aiGutterChanges[0]; + requestAnimationFrame(() => { + editor.revealLineInCenter(targetChange.startLine); + editor.setPosition({ lineNumber: targetChange.startLine, column: 1 }); + editor.focus(); + showDiffPeek(targetChange, true); + }); + } + + } + + // Register callback for AI change notifications (fires when a new change arrives) + onMount(() => { + const unregister = onAiChange(() => { + applyAiChangeDecorations(); + }); + const unregisterReveal = onAiScrollReveal((revealPath) => { + const currentPath = file?.path || ''; + if (revealPath === currentPath) { + applyAiChangeDecorations(); + } + }); + return () => { + unregister(); + unregisterReveal(); + }; + }); + function scheduleGutterUpdate() { if (gutterUpdateTimer) clearTimeout(gutterUpdateTimer); gutterUpdateTimer = setTimeout(() => { @@ -415,15 +595,26 @@ return type === 'added' ? '#10b981' : type === 'modified' ? '#3b82f6' : '#ef4444'; } - function updateGutterDecorations() { + function updateGutterDecorations(forceClear = false) { const editor = monacoEditorRef?.getEditor(); if (!editor) return; + if (forceClear || gutterMode === 'ai') { + gutterChanges = []; + gutterDecorations = editor.deltaDecorations(gutterDecorations, []); + if (activeDiffZone && !activeDiffZone.isAi) { + closeDiffPeek(); + } + return; + } + // No HEAD content (untracked, missing repo) — clear any existing gutter if (headContent === null || headContent === undefined) { gutterChanges = []; gutterDecorations = editor.deltaDecorations(gutterDecorations, []); - closeDiffPeek(); + if (activeDiffZone && !activeDiffZone.isAi) { + closeDiffPeek(); + } return; } @@ -431,7 +622,7 @@ gutterChanges = changes; // Close any open peek whose anchor line is no longer marked as changed - if (activeDiffZone) { + if (activeDiffZone && !activeDiffZone.isAi) { const stillExists = changes.some( (c) => activeDiffZone!.line >= c.startLine && activeDiffZone!.line <= c.endLine ); @@ -465,9 +656,12 @@ gutterDecorations = editor.deltaDecorations(gutterDecorations, newDecorations); } - function findChangeAtLine(line: number): GutterChange | null { + function findChangeAtLine(line: number): { change: GutterChange; isAi: boolean } | null { for (const change of gutterChanges) { - if (line >= change.startLine && line <= change.endLine) return change; + if (line >= change.startLine && line <= change.endLine) return { change, isAi: false }; + } + for (const change of aiGutterChanges) { + if (line >= change.startLine && line <= change.endLine) return { change, isAi: true }; } return null; } @@ -545,7 +739,7 @@ }); } - function buildPeekDom(change: GutterChange): HTMLElement { + function buildPeekDom(change: GutterChange, isAi = false): HTMLElement { const root = document.createElement('div'); root.className = `git-diff-peek git-diff-peek-${change.type}`; @@ -553,6 +747,8 @@ inner.className = 'git-diff-peek-inner'; root.appendChild(inner); + + // Old (HEAD) lines — red background, shown for deletions and modifications if (change.oldLines.length > 0) { const body = document.createElement('div'); @@ -691,7 +887,8 @@ function buildPeekOverlayHeader( change: GutterChange, index: number, - total: number + total: number, + isAi = false ): HTMLElement { const root = document.createElement('div'); root.className = `git-diff-peek-overlay-header git-diff-peek-overlay-header-${change.type}`; @@ -725,14 +922,16 @@ attachPeekButton(nextBtn, () => navigatePeek(1)); actions.appendChild(nextBtn); - const discardBtn = document.createElement('button'); - discardBtn.className = 'git-diff-peek-discard-btn'; - discardBtn.type = 'button'; - discardBtn.title = 'Discard this change (revert to HEAD)'; - discardBtn.setAttribute('aria-label', 'Discard this change'); - discardBtn.innerHTML = `${ICON_DISCARD}Discard`; - attachPeekButton(discardBtn, () => discardHunk(change)); - actions.appendChild(discardBtn); + if (!isAi) { + const discardBtn = document.createElement('button'); + discardBtn.className = 'git-diff-peek-discard-btn'; + discardBtn.type = 'button'; + discardBtn.title = 'Discard this change (revert to HEAD)'; + discardBtn.setAttribute('aria-label', 'Discard this change'); + discardBtn.innerHTML = `${ICON_DISCARD}Discard`; + attachPeekButton(discardBtn, () => discardHunk(change)); + actions.appendChild(discardBtn); + } const closeBtn = document.createElement('button'); closeBtn.className = 'git-diff-peek-iconbtn git-diff-peek-close'; @@ -748,18 +947,20 @@ } function navigatePeek(direction: 1 | -1) { - if (!activeDiffZone || gutterChanges.length === 0) return; + if (!activeDiffZone) return; + const changes = getActiveChanges(activeDiffZone.isAi); + if (changes.length === 0) return; const currentLine = activeDiffZone.line; - const currentIdx = gutterChanges.findIndex( + const currentIdx = changes.findIndex( (c) => c.startLine === currentLine ); if (currentIdx === -1) return; const nextIdx = - (currentIdx + direction + gutterChanges.length) % gutterChanges.length; - const next = gutterChanges[nextIdx]; + (currentIdx + direction + changes.length) % changes.length; + const next = changes[nextIdx]; const editor = monacoEditorRef?.getEditor(); if (editor) editor.revealLineInCenter(next.startLine); - showDiffPeek(next); + showDiffPeek(next, activeDiffZone.isAi); } function applyPeekSizing(editorInstance: editor.IStandaloneCodeEditor, domNode: HTMLElement) { @@ -783,17 +984,19 @@ domNode.style.transform = `translateX(${scrollLeft}px)`; } - function showDiffPeek(change: GutterChange) { + function showDiffPeek(change: GutterChange, isAi = false) { const editorInstance = monacoEditorRef?.getEditor(); if (!editorInstance) return; closeDiffPeek(); - const index = gutterChanges.indexOf(change) + 1; - const total = gutterChanges.length; - const domNode = buildPeekDom(change); + const changes = getActiveChanges(isAi); + const foundIdx = changes.findIndex(c => c.startLine === change.startLine && c.type === change.type); + const index = foundIdx >= 0 ? foundIdx + 1 : 1; + const total = changes.length; + const domNode = buildPeekDom(change, isAi); const marginDomNode = buildPeekMargin(change); - const overlayHeader = buildPeekOverlayHeader(change, index, total); + const overlayHeader = buildPeekOverlayHeader(change, index, total, isAi); applyPeekSizing(editorInstance, domNode); applyPeekScroll(domNode, editorInstance.getScrollLeft()); @@ -921,6 +1124,7 @@ activeDiffZone = { id: zoneId, line: change.startLine, + isAi, escHandler, domNode, overlayWidget, @@ -930,6 +1134,26 @@ }; } + function getActiveChanges(isAi: boolean): GutterChange[] { + return isAi ? aiGutterChanges : gutterChanges; + } + + function refreshActiveDiffPeek() { + if (!activeDiffZone) return; + const isAi = activeDiffZone.isAi; + const changes = getActiveChanges(isAi); + if (changes.length === 0) { + closeDiffPeek(); + return; + } + const targetChange = changes[0]; + showDiffPeek(targetChange, isAi); + const editor = monacoEditorRef?.getEditor(); + if (editor) { + editor.revealLineInCenterIfOutsideViewport(targetChange.startLine); + } + } + function closeDiffPeek() { if (!activeDiffZone) return; const editorInstance = monacoEditorRef?.getEditor(); @@ -954,12 +1178,13 @@ if (e.target.type !== GUTTER_LINE_DECORATIONS) return; const line = e.target.position?.lineNumber; if (!line) return; - const change = findChangeAtLine(line); - if (!change) return; + const found = findChangeAtLine(line); + if (!found) return; + const { change, isAi } = found; if (activeDiffZone && activeDiffZone.line === change.startLine) { closeDiffPeek(); } else { - showDiffPeek(change); + showDiffPeek(change, isAi); } }); gutterClickDispose = () => disposable.dispose(); @@ -1003,6 +1228,7 @@ // Reset decoration ids — the prior editor instance is gone so its ids // are no longer valid. gutterDecorations = []; + aiChangeDecorations = []; // Previous editor's view zones are gone with the disposed instance if (activeDiffZone) { @@ -1044,6 +1270,7 @@ } scheduleGutterUpdate(); + applyAiChangeDecorations(); } // Handle content changes from editor @@ -1051,16 +1278,27 @@ hasChanges = newContent !== referenceContent; onContentChange?.(newContent); // User edits invalidate the captured HEAD-side hunk in the peek; close it - // so the next click reflects the up-to-date diff. if (activeDiffZone) closeDiffPeek(); scheduleGutterUpdate(); + // Clear AI change highlights when user starts editing + if (file?.path) { + clearAiChange(file.path); + const editor = monacoEditorRef?.getEditor(); + if (editor) aiChangeDecorations = editor.deltaDecorations(aiChangeDecorations, []); + } } - // Expose scroll position to parent (FilesPanel snapshots it on tab switches) export function getEditorScrollTop(): number { return monacoEditorRef?.getScrollTop?.() ?? 0; } + export function resetRevealFilter() { + activeRevealEditIdx = null; + lastRevealEditIdx = null; + setGutterViewMode('git'); + closeDiffPeek(); + } + // Update Monaco word wrap when prop changes // Read wordWrap BEFORE the if-check so it's always tracked by $effect $effect(() => { @@ -1394,6 +1632,50 @@ {/if} + + {#if hasAiChanges} + + {/if} + + {#if gutterMode === 'ai' && lastRevealEditIdx !== null} + {#if activeRevealEditIdx !== null} + + {:else} + + {/if} + {/if}