From 633c4e0e55dc2124a3ca05981eed073e25572c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 22:43:47 +0000 Subject: [PATCH] feat: improve changes panel (#234) --- .../codemirror/codemirror-code-editor.tsx | 83 +- .../editors/monaco/monaco-code-editor.tsx | 193 ++- .../editors/monaco/monaco-diff-viewer.tsx | 12 +- .../editors/monaco/monaco-inline-diff.tsx | 18 +- apps/web/components/task/browser-panel.tsx | 6 +- apps/web/components/task/changes-panel.tsx | 1149 ++++++++++++----- .../components/task/commit-detail-panel.tsx | 177 +++ .../task/dockview-desktop-layout.tsx | 111 +- .../components/task/file-editor-content.tsx | 8 +- .../web/components/task/file-editor-panel.tsx | 50 +- apps/web/components/task/files-panel.tsx | 2 +- apps/web/components/task/panel-primitives.tsx | 40 + .../web/components/task/task-center-panel.tsx | 1 - .../components/task/task-changes-panel.tsx | 238 ++-- apps/web/hooks/use-file-editors.ts | 68 +- apps/web/lib/diff/adapter.ts | 23 + apps/web/lib/diff/index.ts | 1 + apps/web/lib/state/dockview-store.ts | 14 + 18 files changed, 1586 insertions(+), 608 deletions(-) create mode 100644 apps/web/components/task/commit-detail-panel.tsx diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 9b9b91ef9c..f0039cc34a 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -14,13 +14,15 @@ import { toRelativePath } from '@/lib/utils'; import { vscodeDark } from '@uiw/codemirror-theme-vscode'; import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice'; import type { DiffComment } from '@/lib/diff/types'; +import { computeLineDiffStats } from '@/lib/diff'; import { useToast } from '@/components/toast-provider'; import { EditorCommentPopover } from '@/components/task/editor-comment-popover'; import { CommentViewPopover } from '@/components/task/comment-view-popover'; +import { PanelHeaderBarSplit } from '@/components/task/panel-primitives'; +import { useDockviewStore } from '@/lib/state/dockview-store'; type FileEditorContentProps = { path: string; - content: string; originalContent: string; isDirty: boolean; isSaving: boolean; @@ -126,7 +128,6 @@ class CommentGutterMarker extends GutterMarker { export function CodeMirrorCodeEditor({ path, - content, originalContent, isDirty, isSaving, @@ -137,6 +138,12 @@ export function CodeMirrorCodeEditor({ onSave, onDelete, }: FileEditorContentProps) { + // Read initial content from the store once (not reactive) + const [initialContent] = useState( + () => useDockviewStore.getState().openFiles.get(path)?.content ?? '' + ); + const contentRef = useRef(initialContent); + const [wrapEnabled, setWrapEnabled] = useState(true); const [textSelection, setTextSelection] = useState(null); const [floatingButtonPos, setFloatingButtonPos] = useState(null); @@ -354,33 +361,15 @@ export function CodeMirrorCodeEditor({ return exts; }, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]); - // Calculate diff stats when dirty - const diffStats = useMemo(() => { - if (!isDirty) return null; - - const originalLines = originalContent.split('\n'); - const currentLines = content.split('\n'); - - let additions = 0; - let deletions = 0; - - const maxLen = Math.max(originalLines.length, currentLines.length); - for (let i = 0; i < maxLen; i++) { - const origLine = originalLines[i]; - const currLine = currentLines[i]; - - if (origLine === undefined && currLine !== undefined) { - additions++; - } else if (origLine !== undefined && currLine === undefined) { - deletions++; - } else if (origLine !== currLine) { - additions++; - deletions++; - } - } - - return { additions, deletions }; - }, [isDirty, content, originalContent]); + // Diff stats — updated via onChange, not React props + const [diffStats, setDiffStats] = useState<{ additions: number; deletions: number } | null>(null); + const statsTimerRef = useRef | null>(null); + const computeDiffStats = useCallback(() => { + if (!isDirty) { setDiffStats(null); return; } + setDiffStats(computeLineDiffStats(originalContent, contentRef.current)); + }, [isDirty, originalContent]); + // Compute on mount and when settings change + useEffect(() => { computeDiffStats(); }, [computeDiffStats]); // Handle Cmd+I to open comment popover useEffect(() => { @@ -429,9 +418,13 @@ export function CodeMirrorCodeEditor({ const handleChange = useCallback( (value: string) => { + contentRef.current = value; onChange(value); + // Debounce diff stats update + if (statsTimerRef.current) clearTimeout(statsTimerRef.current); + statsTimerRef.current = setTimeout(computeDiffStats, 300); }, - [onChange] + [onChange, computeDiffStats] ); // Open comment popover from floating button @@ -517,16 +510,19 @@ export function CodeMirrorCodeEditor({ return (
{/* Editor header with save button */} -
-
- {toRelativePath(path, worktreePath)} - {isDirty && diffStats && ( - - {formatDiffStats(diffStats.additions, diffStats.deletions)} - - )} -
-
+ + {toRelativePath(path, worktreePath)} + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+ } + right={ +
{enableComments && sessionId && comments.length > 0 && (
@@ -583,14 +579,15 @@ export function CodeMirrorCodeEditor({ )} -
-
+
+ } + /> {/* CodeMirror editor */}
useDockviewStore.getState().openFiles.get(path)?.content ?? '' + ); + // Track current content via ref for non-React uses (diff decorations, LSP sync, diff stats) + const contentRef = useRef(initialContent); + const [wrapEnabled, setWrapEnabled] = useState(true); const [textSelection, setTextSelection] = useState(null); const [floatingButtonPos, setFloatingButtonPos] = useState(null); @@ -157,6 +166,8 @@ export function MonacoCodeEditor({ const editorRef = useRef(null); const wrapperRef = useRef(null); const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const onSaveRef = useRef(onSave); + onSaveRef.current = onSave; const decorationsRef = useRef(null); const diffDecorationsRef = useRef(null); const disposablesRef = useRef([]); @@ -182,7 +193,7 @@ export function MonacoCodeEditor({ const documentUri = `file://${monacoPath}`; useEffect(() => { if (!hasLspActive || !lspSessionId || !lspLanguage) return; - lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content); + lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, contentRef.current); return () => { lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri); }; @@ -190,18 +201,29 @@ export function MonacoCodeEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]); - // LSP document change: notify server of content changes (debounced) + // LSP document change: notify server of content changes via Monaco model events (not React props) const changeTimerRef = useRef | null>(null); useEffect(() => { - if (!hasLspActive || !lspSessionId || !lspLanguage) return; - if (changeTimerRef.current) clearTimeout(changeTimerRef.current); - changeTimerRef.current = setTimeout(() => { - lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, content); - }, 300); + const editor = editorRef.current; + if (!editor || !hasLspActive || !lspSessionId || !lspLanguage) return; + + const model = editor.getModel(); + if (!model) return; + + const disposable = model.onDidChangeContent(() => { + if (changeTimerRef.current) clearTimeout(changeTimerRef.current); + changeTimerRef.current = setTimeout(() => { + lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, contentRef.current); + }, 300); + }); + return () => { if (changeTimerRef.current) clearTimeout(changeTimerRef.current); + disposable.dispose(); }; - }, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]); + // Re-run when LSP readiness or editor instance changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasLspActive, lspSessionId, lspLanguage, documentUri]); // Toast on LSP status changes const lspStateForToast = lspStatus.state; @@ -234,9 +256,25 @@ export function MonacoCodeEditor({ return () => document.removeEventListener('mousemove', handleMouseMove); }, []); + // Cmd/Ctrl+S to save — uses DOM keydown on wrapper instead of Monaco's addCommand + // because addCommand registers globally and conflicts with multiple editor instances. + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + e.stopPropagation(); + onSaveRef.current(); + } + }; + wrapper.addEventListener('keydown', handler); + return () => wrapper.removeEventListener('keydown', handler); + }, []); + // Handle editor mount const handleEditorDidMount: OnMount = useCallback( - (editor) => { + (editor, monaco) => { editorRef.current = editor; decorationsRef.current = editor.createDecorationsCollection([]); diffDecorationsRef.current = editor.createDecorationsCollection([]); @@ -248,14 +286,9 @@ export function MonacoCodeEditor({ editor.revealLineInCenter(pendingPos.line); } - // Cmd/Ctrl+S to save - editor.addCommand( - (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd | - (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS, - () => { - onSave(); - } - ); + // Cmd/Ctrl+S is handled via DOM keydown on the wrapper (see useEffect below) + // because Monaco's addCommand registers globally and conflicts when + // multiple editor instances exist. // Selection change listener for comments if (enableComments && sessionId) { @@ -300,7 +333,7 @@ export function MonacoCodeEditor({ }); disposablesRef.current.push(glyphDisposable); }, - [path, onSave, enableComments, sessionId, comments] + [path, enableComments, sessionId, comments] ); // Cleanup disposables @@ -350,8 +383,9 @@ export function MonacoCodeEditor({ decorationsRef.current.set(decorations); }, [comments]); - // Diff gutter indicators - useEffect(() => { + // Diff gutter indicators — driven by Monaco model change events, not React props. + // We compute decorations using contentRef (updated by onChange) + originalContent prop. + const updateDiffDecorations = useCallback(() => { if (!diffDecorationsRef.current || !editorRef.current) return; if (!showDiffIndicators || !isDirty || !originalContent) { @@ -359,7 +393,8 @@ export function MonacoCodeEditor({ return; } - const changes = diffLines(originalContent, content); + const currentContent = contentRef.current; + const changes = diffLines(originalContent, currentContent); const decorations: monacoEditor.IModelDeltaDecoration[] = []; let currentLine = 1; @@ -368,10 +403,8 @@ export function MonacoCodeEditor({ const lineCount = change.count ?? 0; if (change.removed) { - // Check if next change is an addition (modified lines) const next = changes[i + 1]; if (next && next.added) { - // Modified: removed + added pair const addedLineCount = next.count ?? 0; for (let j = 0; j < addedLineCount; j++) { decorations.push({ @@ -380,9 +413,8 @@ export function MonacoCodeEditor({ }); } currentLine += addedLineCount; - i++; // skip the added part + i++; } else { - // Standalone deletion: red indicator at previous line const indicatorLine = Math.max(1, currentLine - 1); decorations.push({ range: { startLineNumber: indicatorLine, startColumn: 1, endLineNumber: indicatorLine, endColumn: 1 }, @@ -390,7 +422,6 @@ export function MonacoCodeEditor({ }); } } else if (change.added) { - // Standalone addition for (let j = 0; j < lineCount; j++) { decorations.push({ range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 }, @@ -399,13 +430,34 @@ export function MonacoCodeEditor({ } currentLine += lineCount; } else { - // Unchanged currentLine += lineCount; } } diffDecorationsRef.current.set(decorations); - }, [content, originalContent, showDiffIndicators, isDirty]); + }, [originalContent, showDiffIndicators, isDirty]); + + // Update diff decorations on model content changes (debounced) and when settings change + const diffTimerRef = useRef | null>(null); + useEffect(() => { + // Update immediately for settings changes (toggle, save) + updateDiffDecorations(); + + const editor = editorRef.current; + if (!editor) return; + const model = editor.getModel(); + if (!model) return; + + const disposable = model.onDidChangeContent(() => { + if (diffTimerRef.current) clearTimeout(diffTimerRef.current); + diffTimerRef.current = setTimeout(updateDiffDecorations, 150); + }); + + return () => { + if (diffTimerRef.current) clearTimeout(diffTimerRef.current); + disposable.dispose(); + }; + }, [updateDiffDecorations]); // Show floating button after mouse up useEffect(() => { @@ -467,7 +519,10 @@ export function MonacoCodeEditor({ const handleChange: OnChange = useCallback( (value) => { - if (value !== undefined) onChange(value); + if (value !== undefined) { + contentRef.current = value; + onChange(value); + } }, [onChange] ); @@ -534,37 +589,49 @@ export function MonacoCodeEditor({ const handleCommentViewClose = useCallback(() => setCommentView(null), []); - // Diff stats - const diffStats = useMemo(() => { - if (!isDirty) return null; - const originalLines = originalContent.split('\n'); - const currentLines = content.split('\n'); - let additions = 0; - let deletions = 0; - const maxLen = Math.max(originalLines.length, currentLines.length); - for (let i = 0; i < maxLen; i++) { - const origLine = originalLines[i]; - const currLine = currentLines[i]; - if (origLine === undefined && currLine !== undefined) additions++; - else if (origLine !== undefined && currLine === undefined) deletions++; - else if (origLine !== currLine) { additions++; deletions++; } - } - return { additions, deletions }; - }, [isDirty, content, originalContent]); + // Diff stats — updated via Monaco model events, not React props + const [diffStats, setDiffStats] = useState<{ additions: number; deletions: number } | null>(null); + const computeDiffStats = useCallback(() => { + if (!isDirty) { setDiffStats(null); return; } + setDiffStats(computeLineDiffStats(originalContent, contentRef.current)); + }, [isDirty, originalContent]); + + const statsTimerRef = useRef | null>(null); + useEffect(() => { + computeDiffStats(); + + const editor = editorRef.current; + if (!editor) return; + const model = editor.getModel(); + if (!model) return; + + const disposable = model.onDidChangeContent(() => { + if (statsTimerRef.current) clearTimeout(statsTimerRef.current); + statsTimerRef.current = setTimeout(computeDiffStats, 300); + }); + + return () => { + if (statsTimerRef.current) clearTimeout(statsTimerRef.current); + disposable.dispose(); + }; + }, [computeDiffStats]); return (
{/* Editor header */} -
-
- {toRelativePath(path, worktreePath)} - {isDirty && diffStats && ( - - {formatDiffStats(diffStats.additions, diffStats.deletions)} - - )} -
-
+ + {toRelativePath(path, worktreePath)} + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+ } + right={ +
{enableComments && sessionId && comments.length > 0 && (
@@ -638,8 +705,9 @@ export function MonacoCodeEditor({ )} -
-
+
+ } + /> {/* Monaco editor */}
@@ -647,10 +715,11 @@ export function MonacoCodeEditor({ height="100%" language={language} path={monacoPath} - value={content} + defaultValue={initialContent} theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'} onChange={handleChange} onMount={handleEditorDidMount} + keepCurrentModel options={{ fontSize: EDITOR_FONT_SIZE, fontFamily: EDITOR_FONT_FAMILY, diff --git a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx index cfbeb175f7..ca8c6e5b3b 100644 --- a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx +++ b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'; import type { editor as monacoEditor } from 'monaco-editor'; import { useTheme } from 'next-themes'; @@ -117,6 +117,16 @@ export function MonacoDiffViewer({ const diffEditorRef = useRef(null); const wrapperRef = useRef(null); + // Fix: reset the DiffEditor model BEFORE @monaco-editor/react's useEffect cleanup + // disposes the models. useLayoutEffect cleanup runs before useEffect cleanup, + // so the DiffEditorWidget releases model references before models are disposed. + // This prevents "TextModel got disposed before DiffEditorWidget model got reset". + useLayoutEffect(() => { + return () => { + try { diffEditorRef.current?.setModel(null); } catch { /* already disposed */ } + }; + }, []); + // Context menu state type ContextMenuState = { x: number; diff --git a/apps/web/components/editors/monaco/monaco-inline-diff.tsx b/apps/web/components/editors/monaco/monaco-inline-diff.tsx index d9debea30c..20c1e0fe39 100644 --- a/apps/web/components/editors/monaco/monaco-inline-diff.tsx +++ b/apps/web/components/editors/monaco/monaco-inline-diff.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useMemo } from 'react'; -import { DiffEditor } from '@monaco-editor/react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'; +import type { editor as monacoEditor } from 'monaco-editor'; import { useTheme } from 'next-themes'; import { cn } from '@/lib/utils'; import type { FileDiffData } from '@/lib/diff/types'; @@ -19,6 +20,18 @@ type MonacoInlineDiffProps = { export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) { const { resolvedTheme } = useTheme(); const language = getMonacoLanguage(data.filePath); + const diffEditorRef = useRef(null); + + const handleMount: DiffOnMount = useCallback((editor) => { + diffEditorRef.current = editor; + }, []); + + // Fix: reset model before @monaco-editor/react's cleanup disposes models + useLayoutEffect(() => { + return () => { + try { diffEditorRef.current?.setModel(null); } catch { /* already disposed */ } + }; + }, []); const { original, modified } = useMemo(() => { if (data.oldContent || data.newContent) { @@ -66,6 +79,7 @@ export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) { original={original} modified={modified} theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'} + onMount={handleMount} options={{ readOnly: true, fontSize: 11, diff --git a/apps/web/components/task/browser-panel.tsx b/apps/web/components/task/browser-panel.tsx index 22becc7079..2ddf58c1ff 100644 --- a/apps/web/components/task/browser-panel.tsx +++ b/apps/web/components/task/browser-panel.tsx @@ -5,7 +5,7 @@ import type { IDockviewPanelProps } from 'dockview-react'; import { IconRefresh, IconExternalLink } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Input } from '@kandev/ui/input'; -import { PanelRoot, PanelBody, PanelToolbar } from './panel-primitives'; +import { PanelRoot, PanelBody, PanelHeaderBar } from './panel-primitives'; import { useAppStore } from '@/components/state-provider'; import { detectPreviewUrlFromOutput } from '@/lib/preview-url-detector'; @@ -62,7 +62,7 @@ export const BrowserPanel = memo(function BrowserPanel( return ( - + setUrlDraft(e.target.value)} @@ -95,7 +95,7 @@ export const BrowserPanel = memo(function BrowserPanel( > - + {showIframeDelayed ? ( diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 625e72f49e..9b13ec13c4 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -1,20 +1,43 @@ 'use client'; import { memo, useMemo, useState, useCallback, useEffect } from 'react'; -import { PanelRoot, PanelBody } from './panel-primitives'; +import { PanelRoot, PanelBody, PanelHeaderBarSplit } from './panel-primitives'; import { IconArrowBackUp, - IconExternalLink, IconPlus, IconMinus, IconCheck, - IconChevronRight, IconGitCommit, + IconGitPullRequest, IconLoader2, - IconColumns, + IconPencil, + IconCloudDownload, + IconCloudUpload, + IconEye, + IconChevronDown, + IconGitBranch, + IconGitCherryPick, + IconGitMerge, + IconArrowRight, } from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@kandev/ui/hover-card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@kandev/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kandev/ui/dropdown-menu'; import { AlertDialog, AlertDialogAction, @@ -25,21 +48,21 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@kandev/ui/alert-dialog'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { Label } from '@kandev/ui/label'; +import { Textarea } from '@kandev/ui/textarea'; import { LineStat } from '@/components/diff-stat'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; -import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useSessionCommits } from '@/hooks/domains/session/use-session-commits'; import { useGitOperations } from '@/hooks/use-git-operations'; import { useSessionFileReviews } from '@/hooks/use-session-file-reviews'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; import { hashDiff, normalizeDiffContent } from '@/components/review/types'; -import { getWebSocketClient } from '@/lib/ws/connection'; import type { FileInfo } from '@/lib/state/store'; import { useToast } from '@/components/toast-provider'; import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context'; -import { usePanelActions } from '@/hooks/use-panel-actions'; import { FileStatusIcon } from './file-status-icon'; const splitPath = (path: string) => { @@ -51,97 +74,244 @@ const splitPath = (path: string) => { }; }; -type ChangesPanelProps = { - onSelectDiff: (path: string, content?: string) => void; +type ChangedFile = { + path: string; + status: FileInfo['status']; + staged: boolean; + plus: number | undefined; + minus: number | undefined; + oldPath: string | undefined; }; -const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelProps) { +type ChangesPanelProps = { + onOpenFile: (path: string) => void; + onOpenCommitDetail?: (sha: string) => void; + onOpenDiffAll?: () => void; + onOpenReview?: () => void; +}; + +// --- Timeline section dot colors --- +const DOT_COLORS = { + unstaged: 'bg-yellow-500', + staged: 'bg-emerald-500', + commits: 'bg-blue-500', + action: 'bg-muted-foreground/25', +} as const; + +// --- Timeline visual components --- + +function TimelineDot({ color }: { color: string }) { + return ( +
+ ); +} + +function TimelineSection({ + dotColor, + label, + count, + action, + isLast, + children, +}: { + dotColor: string; + label?: string; + count?: number; + action?: React.ReactNode; + isLast?: boolean; + children?: React.ReactNode; +}) { + return ( +
+ {/* Vertical line + dot */} +
+ + {!isLast && ( +
+ )} +
+ + {/* Content */} +
+ {/* Header */} + {label && ( +
+ + {label} + {typeof count === 'number' && count > 0 && ( + ({count}) + )} + + {action} +
+ )} + + {/* Children (file list, buttons, etc.) */} + {children} +
+
+ ); +} + +// --- File row --- + +function FileRow({ + file, + isPending, + onOpenFile, + onStage, + onUnstage, + onDiscard, + onEditFile, +}: { + file: ChangedFile; + isPending: boolean; + onOpenFile: (path: string) => void; + onStage: (path: string) => void; + onUnstage: (path: string) => void; + onDiscard: (path: string) => void; + onEditFile: (path: string) => void; +}) { + const { folder, file: name } = splitPath(file.path); + + return ( +
  • onOpenFile(file.path)} + > +
    + {isPending ? ( +
    + +
    + ) : file.staged ? ( + + ) : ( + + )} + +
    +
    +
    + + + + + Discard changes + + + + + + Edit + +
    + + +
    +
  • + ); +} + +// --- Main component --- + +const ChangesPanel = memo(function ChangesPanel({ onOpenFile, onOpenCommitDetail, onOpenDiffAll, onOpenReview }: ChangesPanelProps) { const isArchived = useIsTaskArchived(); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); + const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); + + // Commit dialog state + const [commitDialogOpen, setCommitDialogOpen] = useState(false); + const [commitMessage, setCommitMessage] = useState(''); + + // PR dialog state + const [prDialogOpen, setPrDialogOpen] = useState(false); + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + const [prDraft, setPrDraft] = useState(true); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const taskTitle = useAppStore((state) => { + if (!state.tasks.activeTaskId) return undefined; + return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title; + }); + const baseBranch = useAppStore((state) => { + if (!activeSessionId) return undefined; + return state.taskSessions.items[activeSessionId]?.base_branch; + }); + const displayBranch = useAppStore((state) => { + if (!activeSessionId) return null; + const gs = state.gitStatus.bySessionId[activeSessionId]; + return gs?.branch ?? null; + }); + const gitStatus = useSessionGitStatus(activeSessionId); const { commits } = useSessionCommits(activeSessionId ?? null); - const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const gitOps = useGitOperations(activeSessionId ?? null); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); const { diff: cumulativeDiff } = useCumulativeDiff(activeSessionId); - // State for commit diffs - const [expandedCommit, setExpandedCommit] = useState(null); - const [commitDiffs, setCommitDiffs] = useState>>({}); - const [loadingCommitSha, setLoadingCommitSha] = useState(null); - const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); + const aheadCount = gitStatus?.ahead ?? 0; + const behindCount = gitStatus?.behind ?? 0; - // Fetch commit diff - const fetchCommitDiff = useCallback(async (commitSha: string) => { - if (!activeSessionId || commitDiffs[commitSha]) return; + // Clean base branch name for display + const baseBranchDisplay = useMemo(() => { + if (!baseBranch) return 'main'; + return baseBranch.replace(/^origin\//, ''); + }, [baseBranch]); - setLoadingCommitSha(commitSha); - try { - const ws = getWebSocketClient(); - if (!ws) return; - const response = await ws.request<{ success: boolean; files: Record }>( - 'session.commit_diff', - { session_id: activeSessionId, commit_sha: commitSha }, - 10000 - ); - if (response?.success && response.files) { - setCommitDiffs((prev) => ({ ...prev, [commitSha]: response.files })); - } - } catch (err) { - toast({ - title: 'Failed to load commit diff', - description: err instanceof Error ? err.message : 'An unexpected error occurred', - variant: 'error', - }); - } finally { - setLoadingCommitSha(null); - } - }, [activeSessionId, commitDiffs, toast]); - - // Toggle commit expansion - const toggleCommit = useCallback((commitSha: string) => { - if (expandedCommit === commitSha) { - setExpandedCommit(null); - } else { - setExpandedCommit(commitSha); - void fetchCommitDiff(commitSha); - } - }, [expandedCommit, fetchCommitDiff]); - - // Handle discard changes - const handleDiscardClick = useCallback((filePath: string) => { - setFileToDiscard(filePath); - setShowDiscardDialog(true); - }, []); - - const handleDiscardConfirm = useCallback(async () => { - if (!fileToDiscard) return; - - try { - const result = await gitOps.discard([fileToDiscard]); - if (!result.success) { - toast({ - title: 'Failed to discard changes', - description: result.error || 'An unknown error occurred', - variant: 'error', - }); - } - } catch (error) { - toast({ - title: 'Failed to discard changes', - description: error instanceof Error ? error.message : 'An unknown error occurred', - variant: 'error', - }); - } finally { - setShowDiscardDialog(false); - setFileToDiscard(null); - } - }, [fileToDiscard, gitOps, toast]); - - // Convert git status files to array for display + // Convert git status files to array const changedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; @@ -156,11 +326,14 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr })); }, [gitStatus]); - // Compute review progress across all files (uncommitted + committed) + // Split into unstaged and staged + const unstagedFiles = useMemo(() => changedFiles.filter((f) => !f.staged), [changedFiles]); + const stagedFiles = useMemo(() => changedFiles.filter((f) => f.staged), [changedFiles]); + + // Review progress const { reviewedCount, totalFileCount } = useMemo(() => { const paths = new Set(); - // Uncommitted if (gitStatus?.files) { for (const [path, file] of Object.entries(gitStatus.files)) { const diff = file.diff ? normalizeDiffContent(file.diff) : ''; @@ -168,7 +341,6 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr } } - // Committed (skip duplicates) if (cumulativeDiff?.files) { for (const [path, file] of Object.entries(cumulativeDiff.files)) { if (paths.has(path)) continue; @@ -182,7 +354,6 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr const state = reviews.get(path); if (!state?.reviewed) continue; - // Check staleness: find the diff content for this path let diffContent = ''; const uncommittedFile = gitStatus?.files?.[path]; if (uncommittedFile?.diff) { @@ -192,7 +363,7 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr } if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) { - continue; // stale — don't count + continue; } reviewed++; } @@ -202,7 +373,7 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr const reviewProgressPercent = totalFileCount > 0 ? (reviewedCount / totalFileCount) * 100 : 0; - // Clear pending stage/unstage spinners when git status updates + // Clear pending spinners when git status updates useEffect(() => { if (pendingStageFiles.size > 0) { setPendingStageFiles(new Set()); @@ -210,6 +381,60 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr // eslint-disable-next-line react-hooks/exhaustive-deps }, [changedFiles]); + // --- Handlers --- + + const handleGitOperation = useCallback( + async ( + operation: () => Promise<{ success: boolean; output: string; error?: string }>, + operationName: string, + ) => { + try { + const result = await operation(); + if (result.success) { + toast({ + title: `${operationName} successful`, + description: result.output.slice(0, 200) || `${operationName} completed`, + variant: 'success', + }); + } else { + toast({ + title: `${operationName} failed`, + description: result.error || 'An error occurred', + variant: 'error', + }); + } + } catch (e) { + toast({ + title: `${operationName} failed`, + description: e instanceof Error ? e.message : 'An unexpected error occurred', + variant: 'error', + }); + } + }, + [toast], + ); + + const handlePull = useCallback(() => { + handleGitOperation(() => gitOps.pull(), 'Pull'); + }, [handleGitOperation, gitOps]); + + const handleRebase = useCallback(() => { + const targetBranch = baseBranch?.replace(/^origin\//, '') || 'main'; + handleGitOperation(() => gitOps.rebase(targetBranch), 'Rebase'); + }, [handleGitOperation, gitOps, baseBranch]); + + const handlePush = useCallback(() => { + handleGitOperation(() => gitOps.push(), 'Push'); + }, [handleGitOperation, gitOps]); + + const handleStageAll = useCallback(async () => { + try { + await gitOps.stage(); + } catch { + // Toast handled by gitOps + } + }, [gitOps]); + const handleStage = useCallback(async (path: string) => { setPendingStageFiles((prev) => new Set(prev).add(path)); try { @@ -236,235 +461,391 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr } }, [gitOps]); - // Open file in editor panel - const { openFile: panelOpenFile } = usePanelActions(); - const handleOpenFileInDocumentPanel = useCallback((path: string) => { - if (!activeSessionId) return; - panelOpenFile(path); - }, [activeSessionId, panelOpenFile]); + const handleDiscardClick = useCallback((filePath: string) => { + setFileToDiscard(filePath); + setShowDiscardDialog(true); + }, []); + + const handleDiscardConfirm = useCallback(async () => { + if (!fileToDiscard) return; + try { + const result = await gitOps.discard([fileToDiscard]); + if (!result.success) { + toast({ + title: 'Failed to discard changes', + description: result.error || 'An unknown error occurred', + variant: 'error', + }); + } + } catch (error) { + toast({ + title: 'Failed to discard changes', + description: error instanceof Error ? error.message : 'An unknown error occurred', + variant: 'error', + }); + } finally { + setShowDiscardDialog(false); + setFileToDiscard(null); + } + }, [fileToDiscard, gitOps, toast]); + + // Commit dialog + const handleOpenCommitDialog = useCallback(() => { + setCommitMessage(''); + setCommitDialogOpen(true); + }, []); + + const handleCommit = useCallback(async () => { + if (!commitMessage.trim()) return; + setCommitDialogOpen(false); + await handleGitOperation( + () => gitOps.commit(commitMessage.trim(), false), + 'Commit', + ); + setCommitMessage(''); + }, [commitMessage, handleGitOperation, gitOps]); + + // PR dialog + const handleOpenPRDialog = useCallback(() => { + setPrTitle(taskTitle || ''); + setPrBody(''); + setPrDialogOpen(true); + }, [taskTitle]); + + const handleCreatePR = useCallback(async () => { + if (!prTitle.trim()) return; + setPrDialogOpen(false); + try { + const result = await gitOps.createPR(prTitle.trim(), prBody.trim(), baseBranch, prDraft); + if (result.success) { + toast({ + title: prDraft ? 'Draft PR created' : 'PR created', + description: result.pr_url || 'PR created successfully', + variant: 'success', + }); + if (result.pr_url) window.open(result.pr_url, '_blank'); + } else { + toast({ + title: 'Create PR failed', + description: result.error || 'An error occurred', + variant: 'error', + }); + } + } catch (e) { + toast({ + title: 'Create PR failed', + description: e instanceof Error ? e.message : 'An error occurred', + variant: 'error', + }); + } + setPrTitle(''); + setPrBody(''); + }, [prTitle, prBody, baseBranch, prDraft, gitOps, toast]); + + // Determine which sections to show + const hasUnstaged = unstagedFiles.length > 0; + const hasStaged = stagedFiles.length > 0; + const hasCommits = commits.length > 0; + const hasChanges = changedFiles.length > 0; + const hasAnything = hasUnstaged || hasStaged || hasCommits; + + // Compute stats for commit dialog + const { stagedFileCount, stagedAdditions, stagedDeletions } = useMemo(() => { + let additions = 0; + let deletions = 0; + const count = stagedFiles.length; + if (gitStatus?.files && count > 0) { + for (const file of Object.values(gitStatus.files) as FileInfo[]) { + if (file.staged) { + additions += file.additions || 0; + deletions += file.deletions || 0; + } + } + } + return { stagedFileCount: count, stagedAdditions: additions, stagedDeletions: deletions }; + }, [gitStatus, stagedFiles]); + + // Determine how many timeline sections exist after commits for "isLast" logic + const showActionButtons = hasCommits; + const lastTimelineSection = hasCommits ? 'action' : hasStaged ? 'staged' : 'unstaged'; if (isArchived) return ; return ( - -
    - {/* Uncommitted changes section */} - {changedFiles.length > 0 && ( -
    -
    - Uncommitted ({changedFiles.length}) -
    -
      - {changedFiles.map((file) => { - const { folder, file: name } = splitPath(file.path); - return ( -
    • onSelectDiff(file.path)} + {/* Toolbar header */} + + + + + ) : undefined + } + right={ + <> + {displayBranch && ( + + + - ) : ( - - )} - + + + + +
      +
      + Your code lives in: + and will be merged into:
      -
      - - - - - Discard changes - - - - - - Open side-by-side - - - - - - Open in external editor - -
      - - -
      -
    • - ); - })} -
    -
    - )} - - {/* Commits section */} - {commits.length > 0 && ( -
    -
    - Commits ({commits.length}) -
    -
      - {commits.map((commit) => { - const isExpanded = expandedCommit === commit.commit_sha; - const files = commitDiffs[commit.commit_sha]; - const isLoading = loadingCommitSha === commit.commit_sha; - return ( -
    • - - {isExpanded && files && ( -
        - {Object.entries(files).map(([path, file]) => { - const { folder, file: fileName } = splitPath(path); - return ( -
      • onSelectDiff(path, file.diff)} - > - - - {folder && {folder}/} - {fileName} - - - +{file.additions ?? 0} - {'/'} - -{file.deletions ?? 0} - -
      • - ); - })} -
      - )} -
    • - ); - })} -
    -
    - )} +
    + + + {baseBranchDisplay} + +
    +
    + + + )} + + + + + + + + Pull + {behindCount > 0 && ( + {behindCount} behind + )} + + + + Rebase + {behindCount > 0 && ( + {behindCount} behind + )} + + + + + } + /> - {/* Empty state */} - {changedFiles.length === 0 && commits.length === 0 && ( -
    - Your changed files will appear here -
    - )} -
    - {/* Review progress — pushed to bottom of panel */} - {totalFileCount > 0 && ( - - -
    window.dispatchEvent(new CustomEvent('switch-to-changes-tab'))} - > -
    -
    -
    - - {reviewedCount}/{totalFileCount} reviewed - + + {/* Scrollable content */} +
    + {/* Empty state */} + {!hasAnything && ( +
    + Your changed files will appear here
    - - - {reviewedCount} of {totalFileCount} files reviewed - - - )} + )} + + {/* Timeline */} + {hasAnything && ( +
    + {/* UNSTAGED section */} + {hasUnstaged && ( + + Stage all + + } + > +
      + {unstagedFiles.map((file) => ( + + ))} +
    +
    + )} + + {/* STAGED section — only show when there are staged files */} + {hasStaged && ( + + Commit + + } + > +
      + {stagedFiles.map((file) => ( + + ))} +
    +
    + )} + + {/* COMMITS section */} + {hasCommits && ( + +
      + {commits.map((commit) => ( +
    • onOpenCommitDetail?.(commit.commit_sha)} + > + + + {commit.commit_sha.slice(0, 7)} + + + {commit.commit_message} + + + +{commit.insertions} + {' '} + -{commit.deletions} + +
    • + ))} +
    +
    + )} + + {/* Action buttons (Create PR / Push) — connected to timeline */} + {showActionButtons && ( + +
    + + +
    +
    + )} +
    + )} +
    + + {/* Review progress — pinned to bottom */} + {totalFileCount > 0 && ( + + +
    +
    +
    +
    + + {reviewedCount}/{totalFileCount} reviewed + +
    + + + {reviewedCount} of {totalFileCount} files reviewed + + + )} + {/* Discard confirmation dialog */} @@ -484,7 +865,159 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr - + + {/* Commit Dialog */} + + + + + + Commit Changes + + +
    +
    + {stagedFileCount > 0 ? ( + + {stagedFileCount}{' '} + staged file{stagedFileCount !== 1 ? 's' : ''} + {(stagedAdditions > 0 || stagedDeletions > 0) && ( + + (+{stagedAdditions} + {' / '} + -{stagedDeletions}) + + )} + + ) : ( + No staged files to commit + )} +
    +