feat: improve changes panel (#234)

This commit is contained in:
Carlos Florêncio
2026-02-16 22:43:47 +00:00
committed by GitHub
parent 3af7a692bf
commit 633c4e0e55
18 changed files with 1586 additions and 608 deletions
@@ -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<TextSelection>(null);
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(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<ReturnType<typeof setTimeout> | 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 (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
{/* Editor header with save button */}
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<div className="flex items-center gap-1">
<PanelHeaderBarSplit
left={
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
}
right={
<div className="flex items-center gap-1">
{enableComments && sessionId && comments.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
<IconMessagePlus className="h-3.5 w-3.5" />
@@ -583,14 +579,15 @@ export function CodeMirrorCodeEditor({
</>
)}
</Button>
</div>
</div>
</div>
}
/>
{/* CodeMirror editor */}
<div className="flex-1 overflow-hidden relative">
<CodeMirror
ref={editorRef}
value={content}
value={initialContent}
height="100%"
theme={vscodeDark}
extensions={extensions}
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import Editor, { type OnMount, type OnChange } from '@monaco-editor/react';
import type { editor as monacoEditor, IDisposable } from 'monaco-editor';
import { useTheme } from 'next-themes';
@@ -13,15 +13,18 @@ import { getMonacoLanguage } from '@/lib/editor/language-map';
import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
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 { diffLines } from 'diff';
import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
import { PanelHeaderBarSplit } from '@/components/task/panel-primitives';
import { useAppStore } from '@/components/state-provider';
import { useLsp } from '@/hooks/use-lsp';
import type { LspStatus } from '@/lib/lsp/lsp-client-manager';
import { lspClientManager } from '@/lib/lsp/lsp-client-manager';
import { useDockviewStore } from '@/lib/state/dockview-store';
import { consumePendingCursorPosition } from '@/hooks/use-file-editors';
import { initMonacoThemes } from './monaco-init';
@@ -29,7 +32,6 @@ initMonacoThemes();
type MonacoCodeEditorProps = {
path: string;
content: string;
originalContent: string;
isDirty: boolean;
isSaving: boolean;
@@ -136,7 +138,6 @@ function LspStatusButton({
export function MonacoCodeEditor({
path,
content,
originalContent,
isDirty,
isSaving,
@@ -148,6 +149,14 @@ export function MonacoCodeEditor({
onDelete,
}: MonacoCodeEditorProps) {
const { resolvedTheme } = useTheme();
// Read initial content from the store once (not reactive — Monaco manages content internally)
const [initialContent] = useState(
() => 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<TextSelection>(null);
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
@@ -157,6 +166,8 @@ export function MonacoCodeEditor({
const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
const decorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
const diffDecorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
const disposablesRef = useRef<IDisposable[]>([]);
@@ -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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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 (
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
{/* Editor header */}
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
<div className="flex items-center gap-1">
<PanelHeaderBarSplit
left={
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
{formatDiffStats(diffStats.additions, diffStats.deletions)}
</span>
)}
</div>
}
right={
<div className="flex items-center gap-1">
{enableComments && sessionId && comments.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
<IconMessagePlus className="h-3.5 w-3.5" />
@@ -638,8 +705,9 @@ export function MonacoCodeEditor({
</>
)}
</Button>
</div>
</div>
</div>
}
/>
{/* Monaco editor */}
<div className="flex-1 overflow-hidden relative">
@@ -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,
@@ -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<monacoEditor.IStandaloneDiffEditor | null>(null);
const wrapperRef = useRef<HTMLDivElement>(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;
@@ -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<monacoEditor.IStandaloneDiffEditor | null>(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,
+3 -3
View File
@@ -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 (
<PanelRoot>
<PanelToolbar>
<PanelHeaderBar>
<Input
value={displayDraft}
onChange={(e) => setUrlDraft(e.target.value)}
@@ -95,7 +95,7 @@ export const BrowserPanel = memo(function BrowserPanel(
>
<IconRefresh className="h-4 w-4" />
</Button>
</PanelToolbar>
</PanelHeaderBar>
<PanelBody padding={false} scroll={false}>
{showIframeDelayed ? (
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
'use client';
import { memo, useState, useEffect, useCallback, useMemo } from 'react';
import type { IDockviewPanelProps } from 'dockview-react';
import { IconLoader2 } from '@tabler/icons-react';
import { PanelRoot, PanelBody } from './panel-primitives';
import { FileDiffViewer } from '@/components/diff';
import { useAppStore } from '@/components/state-provider';
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useToast } from '@/components/toast-provider';
import { usePanelActions } from '@/hooks/use-panel-actions';
import type { FileInfo } from '@/lib/state/store';
type CommitDetailParams = {
commitSha: string;
};
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
}
function getInitials(name: string): string {
return name
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase();
}
const CommitDetailPanel = memo(function CommitDetailPanel(
props: IDockviewPanelProps<CommitDetailParams>,
) {
const commitSha = props.params.commitSha;
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
const { toast } = useToast();
const { openFile } = usePanelActions();
const [files, setFiles] = useState<Record<string, FileInfo> | null>(null);
const [loading, setLoading] = useState(false);
// Find commit metadata
const commit = useMemo(
() => commits.find((c) => c.commit_sha === commitSha),
[commits, commitSha],
);
// Update tab title
useEffect(() => {
if (commit) {
const shortSha = commitSha.slice(0, 7);
const msg = commit.commit_message.length > 30
? commit.commit_message.slice(0, 30) + '...'
: commit.commit_message;
props.api.setTitle(`${shortSha} ${msg}`);
}
}, [commit, commitSha, props.api]);
// Fetch diff
const fetchDiff = useCallback(async () => {
if (!activeSessionId) return;
setLoading(true);
try {
const ws = getWebSocketClient();
if (!ws) return;
const response = await ws.request<{ success: boolean; files: Record<string, FileInfo> }>(
'session.commit_diff',
{ session_id: activeSessionId, commit_sha: commitSha },
10000,
);
if (response?.success && response.files) {
setFiles(response.files);
}
} catch (err) {
toast({
title: 'Failed to load commit diff',
description: err instanceof Error ? err.message : 'An unexpected error occurred',
variant: 'error',
});
} finally {
setLoading(false);
}
}, [activeSessionId, commitSha, toast]);
useEffect(() => {
fetchDiff();
}, [fetchDiff]);
const fileEntries = useMemo(() => {
if (!files) return [];
return Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
}, [files]);
if (loading) {
return (
<PanelRoot>
<PanelBody>
<div className="flex items-center justify-center h-full gap-2 text-muted-foreground text-sm">
<IconLoader2 className="h-4 w-4 animate-spin" />
Loading commit...
</div>
</PanelBody>
</PanelRoot>
);
}
return (
<PanelRoot>
<PanelBody padding={false} scroll>
<div className="p-3">
{/* Commit header */}
{commit && (
<div className="mb-4 pb-3 border-b border-border">
<div className="flex items-start gap-3">
{/* Author avatar */}
<div className="flex items-center justify-center size-8 rounded-full bg-muted text-xs font-semibold text-muted-foreground shrink-0">
{getInitials(commit.author_name)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground leading-snug">
{commit.commit_message}
</p>
<p className="text-xs text-muted-foreground mt-1">
{commit.author_name}
<span className="mx-1.5">&middot;</span>
{formatRelativeTime(commit.committed_at)}
<span className="mx-1.5">&middot;</span>
<code className="font-mono text-[11px]">{commitSha.slice(0, 7)}</code>
</p>
</div>
</div>
</div>
)}
</div>
{/* File diffs */}
{fileEntries.length === 0 && !loading && (
<div className="text-sm text-muted-foreground text-center py-8">
No files in this commit
</div>
)}
{fileEntries.map(([path, file]) => (
<div key={path} className="mb-2">
{file.diff ? (
<FileDiffViewer
filePath={path}
diff={file.diff}
status={file.status}
onOpenFile={openFile}
/>
) : (
<div className="px-3 py-2 text-xs text-muted-foreground">
{path} — binary or empty diff
</div>
)}
</div>
))}
</PanelBody>
</PanelRoot>
);
});
export { CommitDetailPanel };
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, memo } from 'react';
import { useCallback, useEffect, useRef, useState, memo } from 'react';
import {
DockviewReact,
DockviewDefaultTab,
@@ -43,6 +43,8 @@ import { startProcess } from '@/lib/api';
import { createUserShell } from '@/lib/api/domains/user-shell-api';
import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts';
import { useLspFileOpener } from '@/hooks/use-lsp-file-opener';
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
// Panel components
import { TaskSessionSidebar, NewTaskButton } from './task-session-sidebar';
@@ -54,7 +56,15 @@ import { TaskPlanPanel } from './task-plan-panel';
import { FileEditorPanel } from './file-editor-panel';
import { TerminalPanel } from './terminal-panel';
import { BrowserPanel } from './browser-panel';
import { CommitDetailPanel } from './commit-detail-panel';
import { PreviewController } from './preview/preview-controller';
import { ReviewDialog } from '@/components/review/review-dialog';
import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff';
import { useGitOperations } from '@/hooks/use-git-operations';
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useToast } from '@/components/toast-provider';
import type { DiffComment } from '@/lib/diff/types';
import type { Repository, RepositoryScript, Task, ProcessInfo } from '@/lib/types/http';
import type { ProcessStatusEntry } from '@/lib/state/slices';
@@ -131,14 +141,50 @@ function DiffViewerPanelComponent() {
);
}
function ChangesPanelWrapper() {
function ChangesPanelWrapper(props: IDockviewPanelProps) {
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
const { openFile } = useFileEditors();
const handleSelectDiff = useCallback((path: string, content?: string) => {
addDiffViewerPanel(path, content);
// Dynamic title with file count
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0;
const totalCount = fileCount + commits.length;
useEffect(() => {
const title = totalCount > 0 ? `Changes (${totalCount})` : 'Changes';
if (props.api.title !== title) {
props.api.setTitle(title);
}
}, [totalCount, props.api]);
const handleOpenFile = useCallback((path: string) => {
openFile(path);
}, [openFile]);
const handleOpenCommitDetail = useCallback((sha: string) => {
addCommitDetailPanel(sha);
}, [addCommitDetailPanel]);
const handleOpenDiffAll = useCallback(() => {
addDiffViewerPanel();
}, [addDiffViewerPanel]);
return <ChangesPanel onSelectDiff={handleSelectDiff} />;
const handleOpenReview = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-review-dialog'));
}, []);
return (
<ChangesPanel
onOpenFile={handleOpenFile}
onOpenCommitDetail={handleOpenCommitDetail}
onOpenDiffAll={handleOpenDiffAll}
onOpenReview={handleOpenReview}
/>
);
}
function FilesPanelWrapper() {
@@ -165,6 +211,7 @@ const components: Record<string, React.FunctionComponent<IDockviewPanelProps>> =
chat: ChatPanel,
'diff-viewer': DiffViewerPanelComponent,
'file-editor': FileEditorPanel,
'commit-detail': CommitDetailPanel,
changes: ChangesPanelWrapper,
files: FilesPanelWrapper,
terminal: TerminalPanel,
@@ -501,6 +548,47 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
const hasDevScript = Boolean(repository?.dev_script?.trim());
// Review dialog state (lives here so it's available from any panel)
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const { toast } = useToast();
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const taskTitle = useAppStore((state) => {
if (!activeTaskId) return undefined;
return state.kanban.tasks.find((t: { id: string }) => t.id === activeTaskId)?.title;
});
const baseBranch = useAppStore((state) => {
if (!effectiveSessionId) return undefined;
return state.taskSessions.items[effectiveSessionId]?.base_branch;
});
const reviewGitStatus = useSessionGitStatus(effectiveSessionId);
const { diff: reviewCumulativeDiff } = useCumulativeDiff(effectiveSessionId);
const { openFile: reviewOpenFile } = useFileEditors();
const handleReviewSendComments = useCallback(
(comments: DiffComment[]) => {
if (!activeTaskId || !effectiveSessionId || comments.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
const markdown = formatReviewCommentsAsMarkdown(comments);
client.request('message.add', {
task_id: activeTaskId,
session_id: effectiveSessionId,
content: markdown,
}, 10000).catch(() => {
toast({ title: 'Failed to send comments', variant: 'error' });
});
setReviewDialogOpen(false);
},
[activeTaskId, effectiveSessionId, toast]
);
// Listen for open-review-dialog events from any panel
useEffect(() => {
const handler = () => setReviewDialogOpen(true);
window.addEventListener('open-review-dialog', handler);
return () => window.removeEventListener('open-review-dialog', handler);
}, []);
// Connect LSP Go-to-Definition navigation to dockview file tabs
useLspFileOpener();
@@ -693,6 +781,19 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
defaultRenderer="always"
className="h-full"
/>
{effectiveSessionId && (
<ReviewDialog
open={reviewDialogOpen}
onOpenChange={setReviewDialogOpen}
sessionId={effectiveSessionId}
baseBranch={baseBranch}
taskTitle={taskTitle}
onSendComments={handleReviewSendComments}
onOpenFile={reviewOpenFile}
gitStatusFiles={reviewGitStatus?.files ?? null}
cumulativeDiff={reviewCumulativeDiff}
/>
)}
</div>
);
});
@@ -1,12 +1,12 @@
'use client';
import { memo } from 'react';
import { useEditorProvider } from '@/hooks/use-editor-resolver';
import { MonacoCodeEditor } from '@/components/editors/monaco/monaco-code-editor';
import { CodeMirrorCodeEditor } from '@/components/editors/codemirror/codemirror-code-editor';
type FileEditorContentProps = {
export type FileEditorContentProps = {
path: string;
content: string;
originalContent: string;
isDirty: boolean;
isSaving: boolean;
@@ -18,9 +18,9 @@ type FileEditorContentProps = {
onDelete?: () => void;
};
export function FileEditorContent(props: FileEditorContentProps) {
export const FileEditorContent = memo(function FileEditorContent(props: FileEditorContentProps) {
const provider = useEditorProvider('code-editor');
return provider === 'monaco'
? <MonacoCodeEditor {...props} />
: <CodeMirrorCodeEditor {...props} />;
}
});
+37 -13
View File
@@ -1,6 +1,6 @@
'use client';
import { memo, useEffect, useRef } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { IDockviewPanelProps } from 'dockview-react';
import { PanelRoot, PanelBody } from './panel-primitives';
import { FileEditorContent } from './file-editor-content';
@@ -18,8 +18,15 @@ export const FileEditorPanel = memo(function FileEditorPanel(
props: IDockviewPanelProps<{ path: string }>
) {
const path = props.params.path;
const fileState = useDockviewStore((s) => s.openFiles.get(path));
// Subscribe to individual metadata fields — NOT the full fileState object.
// This prevents re-renders on every keystroke (content changes don't trigger re-render).
const hasFile = useDockviewStore((s) => s.openFiles.has(path));
const isDirty = useDockviewStore((s) => s.openFiles.get(path)?.isDirty ?? false);
const isBinary = useDockviewStore((s) => s.openFiles.get(path)?.isBinary ?? false);
const originalContent = useDockviewStore((s) => s.openFiles.get(path)?.originalContent ?? '');
const setFileState = useDockviewStore((s) => s.setFileState);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeSession = useAppStore((state) =>
activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null
@@ -29,7 +36,7 @@ export const FileEditorPanel = memo(function FileEditorPanel(
// Self-load file content when state is missing (e.g. after hot reload clears the store)
const loadingRef = useRef(false);
useEffect(() => {
if (fileState || loadingRef.current || !activeSessionId) return;
if (hasFile || loadingRef.current || !activeSessionId) return;
loadingRef.current = true;
const client = getWebSocketClient();
@@ -52,9 +59,20 @@ export const FileEditorPanel = memo(function FileEditorPanel(
})
.catch(() => { /* will stay on loading state */ })
.finally(() => { loadingRef.current = false; });
}, [fileState, activeSessionId, path, setFileState]);
}, [hasFile, activeSessionId, path, setFileState]);
if (!fileState) {
// Stable callbacks — avoid creating new arrow functions on every render
const onChange = useCallback(
(newContent: string) => handleFileChange(path, newContent),
[handleFileChange, path]
);
const onSave = useCallback(() => saveFile(path), [saveFile, path]);
const onDelete = useCallback(() => deleteFile(path), [deleteFile, path]);
// Read image content imperatively (only needed for non-editable image display)
const [imageContent, setImageContent] = useState<string | null>(null);
if (!hasFile) {
return (
<PanelRoot>
<PanelBody padding={false} scroll={false} className="flex items-center justify-center text-muted-foreground text-sm">
@@ -65,19 +83,26 @@ export const FileEditorPanel = memo(function FileEditorPanel(
}
const extCategory = getFileCategory(path);
const category = fileState.isBinary
const category = isBinary
? extCategory === 'image'
? 'image'
: 'binary'
: 'text';
if (category === 'image') {
// Read content imperatively for image display (not edited, so no need for reactive subscription)
const content = imageContent ?? (() => {
const c = useDockviewStore.getState().openFiles.get(path)?.content ?? '';
// Schedule state update outside of render to avoid setState-during-render
queueMicrotask(() => setImageContent(c));
return c;
})();
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<FileImageViewer
path={path}
content={fileState.content}
content={content}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
</PanelBody>
@@ -103,16 +128,15 @@ export const FileEditorPanel = memo(function FileEditorPanel(
<PanelBody padding={false} scroll={false}>
<FileEditorContent
path={path}
content={fileState.content}
originalContent={fileState.originalContent}
isDirty={fileState.isDirty}
originalContent={originalContent}
isDirty={isDirty}
isSaving={savingFiles.has(path)}
sessionId={activeSessionId || undefined}
worktreePath={activeSession?.worktree_path ?? undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => handleFileChange(path, newContent)}
onSave={() => saveFile(path)}
onDelete={() => deleteFile(path)}
onChange={onChange}
onSave={onSave}
onDelete={onDelete}
/>
</PanelBody>
</PanelRoot>
+1 -1
View File
@@ -46,7 +46,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
return (
<PanelRoot>
<PanelBody>
<PanelBody padding={false}>
{activeSessionId ? (
<FileBrowser
sessionId={activeSessionId}
@@ -73,3 +73,43 @@ export function PanelToolbar({ children, className }: PanelToolbarProps) {
</div>
);
}
type PanelHeaderBarProps = {
children?: ReactNode;
className?: string;
};
/** Fixed-height panel header bar. Renders children directly. */
export function PanelHeaderBar({ children, className }: PanelHeaderBarProps) {
return (
<div
className={cn(
'flex items-center gap-2 px-3 h-[30px] border-b border-border shrink-0 bg-card',
className,
)}
>
{children}
</div>
);
}
type PanelHeaderBarSplitProps = {
left?: ReactNode;
right?: ReactNode;
className?: string;
};
/** Panel header bar with left/right slots separated by a spacer. */
export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSplitProps) {
return (
<PanelHeaderBar className={className}>
<div className="flex items-center gap-1.5 min-w-0">
{left}
</div>
<div className="flex-1" />
<div className="flex items-center gap-1.5 min-w-0">
{right}
</div>
</PanelHeaderBar>
);
}
@@ -639,7 +639,6 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
) : (
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={savingFiles.has(tab.path)}
+98 -140
View File
@@ -1,7 +1,7 @@
'use client';
import { memo, useMemo, useCallback, createRef, useState, useEffect, useRef } from 'react';
import { PanelRoot, PanelBody, PanelToolbar } from './panel-primitives';
import { PanelRoot, PanelBody, PanelHeaderBarSplit } from './panel-primitives';
import {
IconSettings,
IconTextWrap,
@@ -30,10 +30,8 @@ import { getWebSocketClient } from '@/lib/ws/connection';
import { updateUserSettings } from '@/lib/api';
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
import { ReviewDiffList } from '@/components/review/review-diff-list';
import { ReviewDialog } from '@/components/review/review-dialog';
import type { ReviewFile } from '@/components/review/types';
import { hashDiff, normalizeDiffContent } from '@/components/review/types';
import type { DiffComment } from '@/lib/diff/types';
import { usePanelActions } from '@/hooks/use-panel-actions';
import type { SelectedDiff } from './task-layout';
import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context';
@@ -59,8 +57,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
return localStorage.getItem('diff-view-mode') === 'split';
});
const [wordWrap, setWordWrap] = useState(false);
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const { toast } = useToast();
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
@@ -277,24 +273,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
markCommentsSent(comments.map((c) => c.id));
}, [activeSessionId, activeTaskId, getPendingComments, markCommentsSent, toast]);
const handleDialogSendComments = useCallback(
(comments: DiffComment[]) => {
if (!activeTaskId || !activeSessionId || comments.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
const markdown = formatReviewCommentsAsMarkdown(comments);
client.request('message.add', {
task_id: activeTaskId,
session_id: activeSessionId,
content: markdown,
}, 10000).catch(() => {
toast({ title: 'Failed to send comments', variant: 'error' });
});
setReviewDialogOpen(false);
},
[activeTaskId, activeSessionId, toast]
);
const reviewedCount = reviewedFiles.size;
const totalCount = allFiles.length;
const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0;
@@ -304,108 +282,103 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
return (
<PanelRoot>
{/* Top bar */}
<PanelToolbar className="bg-card/50 min-h-[36px]">
{/* Settings cog */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="px-1.5 h-7 cursor-pointer">
<IconSettings className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={(e) => {
e.preventDefault();
handleToggleAutoMark(!autoMarkOnScroll);
}}
>
<Checkbox
checked={autoMarkOnScroll}
className="pointer-events-none"
/>
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Progress bar */}
{totalCount > 0 && (
<div className="flex items-center gap-2 min-w-0">
<div className="w-20 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{reviewedCount}/{totalCount} Reviewed
</span>
</div>
)}
<div className="flex-1" />
{/* Expand to review dialog */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-1.5 h-7 cursor-pointer"
onClick={() => setReviewDialogOpen(true)}
>
<IconArrowsMaximize className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand review</TooltipContent>
</Tooltip>
{/* Word wrap */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={`px-1.5 h-7 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
onClick={() => setWordWrap(!wordWrap)}
>
<IconTextWrap className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
</Tooltip>
{/* Split/Unified */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-1.5 h-7 cursor-pointer"
onClick={() => handleToggleSplitView(!splitView)}
>
{splitView ? (
<IconLayoutRows className="h-3.5 w-3.5" />
) : (
<IconLayoutColumns className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{splitView ? 'Unified view' : 'Split view'}</TooltipContent>
</Tooltip>
{/* Fix Comments */}
{totalCommentCount > 0 && (
<Button size="sm" variant="outline" className="h-7 text-xs cursor-pointer" onClick={handleFixComments}>
<IconMessageForward className="h-3.5 w-3.5" />
Fix
<span className="ml-0.5 rounded-full bg-blue-500/30 px-1 py-0 text-[10px] font-medium text-blue-600 dark:text-blue-400">
{totalCommentCount}
</span>
</Button>
)}
</PanelToolbar>
<PanelHeaderBarSplit
left={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="px-1.5 h-5 cursor-pointer">
<IconSettings className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={(e) => {
e.preventDefault();
handleToggleAutoMark(!autoMarkOnScroll);
}}
>
<Checkbox
checked={autoMarkOnScroll}
className="pointer-events-none"
/>
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{totalCount > 0 && (
<div className="flex items-center gap-2 min-w-0">
<div className="w-20 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{reviewedCount}/{totalCount} Reviewed
</span>
</div>
)}
</>
}
right={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-1.5 h-5 cursor-pointer"
onClick={() => window.dispatchEvent(new CustomEvent('open-review-dialog'))}
>
<IconArrowsMaximize className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand review</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={`px-1.5 h-5 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
onClick={() => setWordWrap(!wordWrap)}
>
<IconTextWrap className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle word wrap</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="px-1.5 h-5 cursor-pointer"
onClick={() => handleToggleSplitView(!splitView)}
>
{splitView ? (
<IconLayoutRows className="h-3.5 w-3.5" />
) : (
<IconLayoutColumns className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{splitView ? 'Unified view' : 'Split view'}</TooltipContent>
</Tooltip>
{totalCommentCount > 0 && (
<Button size="sm" variant="outline" className="h-5 text-xs cursor-pointer" onClick={handleFixComments}>
<IconMessageForward className="h-3.5 w-3.5" />
Fix
<span className="ml-0.5 rounded-full bg-blue-500/30 px-1 py-0 text-[10px] font-medium text-blue-600 dark:text-blue-400">
{totalCommentCount}
</span>
</Button>
)}
</>
}
/>
{/* Content */}
<PanelBody padding={false} scroll={false} className="overflow-hidden">
@@ -432,21 +405,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
/>
) : null}
</PanelBody>
{/* Review Dialog */}
{activeSessionId && (
<ReviewDialog
open={reviewDialogOpen}
onOpenChange={setReviewDialogOpen}
sessionId={activeSessionId}
baseBranch={baseBranch}
taskTitle={taskTitle}
onSendComments={handleDialogSendComments}
onOpenFile={handleOpenFile}
gitStatusFiles={gitStatus?.files ?? null}
cumulativeDiff={cumulativeDiff}
/>
)}
</PanelRoot>
);
});
+43 -25
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDockviewStore, type FileEditorState } from '@/lib/state/dockview-store';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
@@ -28,20 +28,29 @@ export function consumePendingCursorPosition(path: string): { line: number; colu
return pos;
}
/** Read openFiles from the store without subscribing to changes. */
function getOpenFiles() {
return useDockviewStore.getState().openFiles;
}
export function useFileEditors() {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const { toast } = useToast();
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
const openFiles = useDockviewStore((s) => s.openFiles);
// No reactive subscription to openFiles — callbacks use getState() instead.
// Persistence is handled by a zustand store subscriber (see below).
const setFileState = useDockviewStore((s) => s.setFileState);
const updateFileState = useDockviewStore((s) => s.updateFileState);
const removeFileState = useDockviewStore((s) => s.removeFileState);
const clearFileStates = useDockviewStore((s) => s.clearFileStates);
const addFileEditorPanel = useDockviewStore((s) => s.addFileEditorPanel);
const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout);
const api = useDockviewStore((s) => s.api);
// Use refs for callbacks that don't need to cause re-renders of consumers.
const activeSessionIdRef = useRef(activeSessionId);
activeSessionIdRef.current = activeSessionId;
// Restore file tabs from sessionStorage on session change.
// The module-level guard prevents multiple hook instances from running this.
useEffect(() => {
@@ -116,15 +125,20 @@ export function useFileEditors() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSessionId]);
// Persist open file tabs to sessionStorage
// Persist open file tabs via zustand store subscriber — does NOT cause React re-renders.
// This replaces the old useEffect that subscribed reactively to `openFiles`.
useEffect(() => {
if (!activeSessionId) return;
// Don't persist while restoration is still loading tabs or during layout restore
if (_restorationInProgress) return;
if (isRestoringLayout) return;
const tabsToSave = Array.from(openFiles.values()).map(({ path, name }) => ({ path, name }));
saveOpenFileTabs(activeSessionId, tabsToSave);
}, [activeSessionId, openFiles, isRestoringLayout]);
const unsub = useDockviewStore.subscribe((state, prevState) => {
if (state.openFiles === prevState.openFiles) return;
const sessionId = activeSessionIdRef.current;
if (!sessionId) return;
if (_restorationInProgress) return;
if (state.isRestoringLayout) return;
const tabsToSave = Array.from(state.openFiles.values()).map(({ path, name }) => ({ path, name }));
saveOpenFileTabs(sessionId, tabsToSave);
});
return unsub;
}, []);
// Persist active center panel to sessionStorage
useEffect(() => {
@@ -152,11 +166,12 @@ export function useFileEditors() {
const openFile = useCallback(async (filePath: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionId;
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
// If already open, just focus it
if (openFiles.has(filePath)) {
// Read current state without subscribing
const files = getOpenFiles();
if (files.has(filePath)) {
const name = filePath.split('/').pop() || filePath;
addFileEditorPanel(filePath, name);
return;
@@ -186,29 +201,32 @@ export function useFileEditors() {
variant: 'error',
});
}
}, [activeSessionId, openFiles, setFileState, addFileEditorPanel, toast]);
}, [setFileState, addFileEditorPanel, toast]);
const handleFileChange = useCallback((path: string, newContent: string) => {
const file = openFiles.get(path);
// Read current state without subscribing
const file = getOpenFiles().get(path);
if (!file) return;
updateFileState(path, {
content: newContent,
isDirty: newContent !== file.originalContent,
});
}, [openFiles, updateFileState]);
}, [updateFileState]);
const saveFile = useCallback(async (path: string) => {
const file = openFiles.get(path);
// Read current state without subscribing
const file = getOpenFiles().get(path);
if (!file || !file.isDirty) return;
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
setSavingFiles((prev) => new Set(prev).add(path));
try {
const diff = generateUnifiedDiff(file.originalContent, file.content, file.path);
const response = await updateFileContent(client, activeSessionId, path, diff, file.originalHash);
const response = await updateFileContent(client, currentSessionId, path, diff, file.originalHash);
if (response.success && response.new_hash) {
updateFileState(path, {
@@ -244,14 +262,15 @@ export function useFileEditors() {
return next;
});
}
}, [openFiles, activeSessionId, updateFileState, toast]);
}, [updateFileState, toast]);
const deleteFileAction = useCallback(async (path: string) => {
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
try {
const response = await deleteFile(client, activeSessionId, path);
const response = await deleteFile(client, currentSessionId, path);
if (response.success) {
// Remove dockview panel (which will trigger removeFileState via onDidRemovePanel)
const dockApi = useDockviewStore.getState().api;
@@ -273,10 +292,9 @@ export function useFileEditors() {
variant: 'error',
});
}
}, [activeSessionId, toast]);
}, [toast]);
return {
openFiles,
savingFiles,
openFile,
saveFile,
+23
View File
@@ -174,3 +174,26 @@ export function extractCodeFromContent(
const lines = content.split('\n');
return lines.slice(startLine - 1, endLine).join('\n');
}
/**
* Compute simple line-level diff stats between two strings.
* Returns the number of added and deleted lines.
*/
export function computeLineDiffStats(
original: string,
current: string,
): { additions: number; deletions: number } {
const originalLines = original.split('\n');
const currentLines = current.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 };
}
+1
View File
@@ -21,6 +21,7 @@ export {
normalizeDiffString,
extractCodeFromDiff,
extractCodeFromContent,
computeLineDiffStats,
type FileMutation,
type ModifyFilePayload,
} from './adapter';
+14
View File
@@ -47,6 +47,7 @@ type DockviewStore = {
addChangesPanel: (groupId?: string) => void;
addFilesPanel: (groupId?: string) => void;
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => void;
addCommitDetailPanel: (sha: string, groupId?: string) => void;
addFileEditorPanel: (path: string, name: string, quiet?: boolean) => void;
addBrowserPanel: (url?: string, groupId?: string) => void;
addPlanPanel: (groupId?: string) => void;
@@ -380,6 +381,19 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
});
},
addCommitDetailPanel: (sha, groupId) => {
const { api, centerGroupId } = get();
if (!api) return;
const panelId = `commit:${sha}`;
focusOrAddPanel(api, {
id: panelId,
component: 'commit-detail',
title: sha.slice(0, 7),
params: { commitSha: sha },
position: { referenceGroup: groupId ?? centerGroupId },
});
},
addFileEditorPanel: (path, name, quiet) => {
const { api, centerGroupId } = get();
if (!api) return;