From 1d7e55e89aad7f1ecd0f1df584a17cc15d21d4ff Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Feb 2026 09:03:50 +0200 Subject: [PATCH] feat: implement editable diff functionality with save and discard options - Added support for editing file content during reviews, allowing users to save changes directly to disk. - Introduced new IPC channels for saving edited files and handling content updates. - Enhanced the review toolbar with actions for saving and discarding edits, along with indicators for edited files. - Implemented keyboard shortcuts for improved navigation and editing experience. - Integrated a new component for rendering CLI logs in a rich format, replacing the previous raw JSON display. - Updated state management to track edited contents and handle file edits effectively. --- CODE_OF_CONDUCT.md | 3 +- package.json | 1 + pnpm-lock.yaml | 13 ++ src/main/index.ts | 7 + src/main/ipc/review.ts | 18 ++ .../services/team/ReviewApplierService.ts | 8 + src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 12 + src/renderer/api/httpClient.ts | 4 + .../components/team/CliLogsRichView.tsx | 161 +++++++++++++ .../team/ProvisioningProgressBlock.tsx | 73 +----- .../team/review/ChangeReviewDialog.tsx | 51 ++++- .../team/review/CodeMirrorDiffView.tsx | 75 +++++- .../team/review/KeyboardShortcutsHelp.tsx | 11 +- .../components/team/review/ReviewToolbar.tsx | 54 ++++- src/renderer/hooks/useDiffNavigation.ts | 117 ++++------ .../store/slices/changeReviewSlice.ts | 47 ++++ src/renderer/utils/streamJsonParser.ts | 214 ++++++++++++++++++ src/shared/types/api.ts | 3 + 19 files changed, 719 insertions(+), 156 deletions(-) create mode 100644 src/renderer/components/team/CliLogsRichView.tsx create mode 100644 src/renderer/utils/streamJsonParser.ts diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 81ecb971..c92708e3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -6,6 +6,7 @@ This project follows the Contributor Covenant Code of Conduct. - Be respectful and constructive. - Assume good intent and discuss ideas, not people. - Give actionable feedback and accept feedback gracefully. +- If a change significantly affects the UI, discuss the design approach with maintainers or the community first so we can align on the best direction. ## Unacceptable Behavior - Harassment, discrimination, or personal attacks. @@ -13,7 +14,7 @@ This project follows the Contributor Covenant Code of Conduct. - Publishing private information without explicit permission. ## Enforcement -Project maintainers are responsible for clarifying and enforcing this code of conduct and may take corrective action for unacceptable behavior. +Maintainers are here to help keep our community welcoming. They’ll clarify expectations when needed and, if necessary, take steps to address behavior that goes against these standards. ## Reporting Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`. diff --git a/package.json b/package.json index adf7be3e..b5e280e9 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ ] }, "dependencies": { + "@codemirror/commands": "^6.10.2", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38d54c8e..257aa8f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@codemirror/commands': + specifier: ^6.10.2 + version: 6.10.2 '@codemirror/lang-css': specifier: ^6.3.1 version: 6.3.1 @@ -396,6 +399,9 @@ packages: '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + '@codemirror/commands@6.10.2': + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + '@codemirror/lang-css@6.3.1': resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} @@ -5562,6 +5568,13 @@ snapshots: '@codemirror/view': 6.39.15 '@lezer/common': 1.5.1 + '@codemirror/commands@6.10.2': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@codemirror/lang-css@6.3.1': dependencies: '@codemirror/autocomplete': 6.20.0 diff --git a/src/main/index.ts b/src/main/index.ts index 613b264d..2521f4c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -551,6 +551,13 @@ function createWindow(): void { return; } + // Prevent Cmd+N from opening new window; forward to renderer for review shortcuts + if (input.meta && input.key.toLowerCase() === 'n') { + event.preventDefault(); + mainWindow.webContents.send('review:cmdN'); + return; + } + if (!input.meta) return; const currentLevel = mainWindow.webContents.getZoomLevel(); diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index b51cc0dd..ba24b913 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -15,6 +15,7 @@ import { REVIEW_PREVIEW_REJECT, REVIEW_REJECT_FILE, REVIEW_REJECT_HUNKS, + REVIEW_SAVE_EDITED_FILE, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; @@ -89,6 +90,8 @@ export function registerReviewHandlers(ipcMain: IpcMain): void { ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject); ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions); ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent); + // Editable diff + ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile); // Phase 4 ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog); } @@ -105,6 +108,8 @@ export function removeReviewHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(REVIEW_PREVIEW_REJECT); ipcMain.removeHandler(REVIEW_APPLY_DECISIONS); ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT); + // Editable diff + ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE); // Phase 4 ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG); } @@ -229,6 +234,19 @@ async function handleGetFileContent( ); } +// --- Editable diff Handlers --- + +async function handleSaveEditedFile( + _event: IpcMainInvokeEvent, + filePath: string, + content: string +): Promise> { + if (!filePath || typeof content !== 'string') { + return { success: false, error: 'Invalid parameters' }; + } + return wrapReviewHandler('saveEditedFile', () => getApplier().saveEditedFile(filePath, content)); +} + // --- Phase 4 Handlers --- async function handleGetGitFileLog( diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 2a94c7d4..6f963cae 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -310,6 +310,14 @@ export class ReviewApplierService { return { applied, skipped, conflicts, errors }; } + /** + * Save edited file content directly to disk. + */ + async saveEditedFile(filePath: string, content: string): Promise<{ success: boolean }> { + await writeFile(filePath, content, 'utf8'); + return { success: true }; + } + // ── Private: Rejection strategies ── /** diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a42671e7..578c213c 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -320,5 +320,8 @@ export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent'; // Phase 4 — Git fallback +/** Save edited file content to disk */ +export const REVIEW_SAVE_EDITED_FILE = 'review:saveEditedFile'; + /** Get git file change log */ export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog'; diff --git a/src/preload/index.ts b/src/preload/index.ts index aa7676a6..a65d8b49 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -20,6 +20,7 @@ import { REVIEW_PREVIEW_REJECT, REVIEW_REJECT_FILE, REVIEW_REJECT_HUNKS, + REVIEW_SAVE_EDITED_FILE, SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, @@ -755,6 +756,17 @@ const electronAPI: ElectronAPI = { snippets ); }, + // Editable diff + saveEditedFile: async (filePath: string, content: string) => { + return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content); + }, + onCmdN: (callback: () => void): (() => void) => { + const handler = () => callback(); + ipcRenderer.on('review:cmdN', handler); + return () => { + ipcRenderer.removeListener('review:cmdN', handler); + }; + }, // Phase 4 getGitFileLog: async (projectPath: string, filePath: string) => { return invokeIpcWithResult<{ hash: string; timestamp: string; message: string }[]>( diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0e4729a0..88b79bc9 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -807,6 +807,10 @@ export class HttpAPIClient implements ElectronAPI { previewReject: async () => { throw new Error('Review is not available in browser mode'); }, + // Editable diff stubs + saveEditedFile: async () => { + throw new Error('Review is not available in browser mode'); + }, // Phase 4 stubs getGitFileLog: async () => { throw new Error('Review is not available in browser mode'); diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx new file mode 100644 index 00000000..626dfeb7 --- /dev/null +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -0,0 +1,161 @@ +/** + * CliLogsRichView + * + * Renders CLI stream-json logs using the same rich components as session views: + * thinking blocks, tool call cards, markdown text output. + * + * Replaces raw JSON display in ProvisioningProgressBlock. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; +import { cn } from '@renderer/lib/utils'; +import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; +import { Bot, ChevronRight } from 'lucide-react'; + +import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; + +interface CliLogsRichViewProps { + cliLogsTail: string; + className?: string; +} + +/** + * A single collapsible group of assistant items. + */ +const StreamGroup = ({ + group, + isExpanded, + onToggle, + expandedItemIds, + onItemClick, +}: { + group: StreamJsonGroup; + isExpanded: boolean; + onToggle: () => void; + expandedItemIds: Set; + onItemClick: (itemId: string) => void; +}): React.JSX.Element => ( +
+ + {isExpanded && ( +
+ +
+ )} +
+); + +export const CliLogsRichView = ({ + cliLogsTail, + className, +}: CliLogsRichViewProps): React.JSX.Element => { + const scrollRef = useRef(null); + // Tracks groups manually collapsed by user (default: all auto-expanded) + const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); + const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); + + const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]); + + // Derive expanded state: all groups expanded unless manually collapsed + const expandedGroupIds = useMemo(() => { + const expanded = new Set(); + for (const group of groups) { + if (!collapsedGroupIds.has(group.id)) { + expanded.add(group.id); + } + } + return expanded; + }, [groups, collapsedGroupIds]); + + // Auto-scroll to bottom on new content + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [cliLogsTail]); + + const handleGroupToggle = useCallback((groupId: string) => { + setCollapsedGroupIds((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + const handleItemClick = useCallback((itemId: string) => { + setExpandedItemIds((prev) => { + const next = new Set(prev); + if (next.has(itemId)) { + next.delete(itemId); + } else { + next.add(itemId); + } + return next; + }); + }, []); + + if (groups.length === 0) { + // cliLogsTail has data but no parseable assistant messages — show raw text fallback + const hasContent = cliLogsTail.trim().length > 0; + return ( +
+ {hasContent ? ( +
+            {cliLogsTail}
+          
+ ) : ( +

+ Waiting for CLI output... +

+ )} +
+ ); + } + + return ( +
+ {groups.map((group) => ( + handleGroupToggle(group.id)} + expandedItemIds={expandedItemIds} + onItemClick={handleItemClick} + /> + ))} +
+ ); +}; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index cea231d4..666b53a5 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -7,59 +7,11 @@ import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { MarkdownViewer } from '../chat/viewers/MarkdownViewer'; +import { CliLogsRichView } from './CliLogsRichView'; import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; import type { ProvisioningStep } from './provisioningSteps'; -// --------------------------------------------------------------------------- -// JSON syntax-highlighted CLI logs -// --------------------------------------------------------------------------- - -const JSON_KEY_COLOR = 'var(--syntax-property, #7dd3fc)'; -const JSON_STRING_COLOR = 'var(--syntax-string, #86efac)'; -const JSON_NUMBER_COLOR = 'var(--syntax-number, #fde68a)'; -const JSON_BOOL_NULL_COLOR = 'var(--syntax-keyword, #c4b5fd)'; -const JSON_BRACKET_COLOR = 'var(--color-text-muted)'; - -function syntaxHighlightJson(json: string): string { - return ( - json - .replace(/("(?:\\.|[^"\\])*")\s*:/g, `$1:`) - .replace(/:\s*("(?:\\.|[^"\\])*")/g, (match, str: string) => - match.replace(str, `${str}`) - ) - // eslint-disable-next-line security/detect-unsafe-regex -- number format is bounded, input is our JSON - .replace(/:\s*(-?\d+(?:\.\d{1,20})?(?:[eE][+-]?\d{1,5})?)/g, (match, num: string) => - match.replace(num, `${num}`) - ) - .replace(/:\s*(true|false|null)/g, (match, kw: string) => - match.replace(kw, `${kw}`) - ) - .replace(/([{}[\]])/g, `$1`) - ); -} - -function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>'); -} - -function prettyFormatLogs(raw: string): string { - return raw - .split('\n') - .map((line) => { - const trimmed = line.trim(); - if (!trimmed) return ''; - try { - const parsed = JSON.parse(trimmed) as unknown; - const pretty = escapeHtml(JSON.stringify(parsed, null, 2)); - return syntaxHighlightJson(pretty); - } catch { - return escapeHtml(trimmed); - } - }) - .join('\n'); -} - export interface ProvisioningProgressBlockProps { /** Title above the steps, e.g. "Launching team" */ title: string; @@ -125,19 +77,7 @@ export const ProvisioningProgressBlock = ({ }: ProvisioningProgressBlockProps): React.JSX.Element => { const elapsed = useElapsedTimer(startedAt); const [logsOpen, setLogsOpen] = useState(false); - const logsRef = useRef(null); const outputScrollRef = useRef(null); - const prettyLogs = useMemo( - () => (cliLogsTail ? prettyFormatLogs(cliLogsTail) : ''), - [cliLogsTail] - ); - - // Auto-scroll CLI logs - useEffect(() => { - if (logsOpen && logsRef.current) { - logsRef.current.scrollTop = logsRef.current.scrollHeight; - } - }, [logsOpen, cliLogsTail]); // Auto-scroll assistant output useEffect(() => { @@ -231,14 +171,7 @@ export const ProvisioningProgressBlock = ({ {logsOpen ? : } CLI logs - {logsOpen ? ( -
-          ) : null}
+          {logsOpen ?  : null}
         
       ) : null}
     
diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx
index 51e45d7b..34529c4f 100644
--- a/src/renderer/components/team/review/ChangeReviewDialog.tsx
+++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx
@@ -1,5 +1,6 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
+import { goToNextChunk, rejectChunk } from '@codemirror/merge';
 import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
 import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
 import { cn } from '@renderer/lib/utils';
@@ -74,11 +75,18 @@ export const ChangeReviewDialog = ({
     acceptAll,
     rejectAll,
     applyReview,
+    // Editable diff
+    editedContents,
+    updateEditedContent,
+    discardFileEdits,
+    saveEditedFile,
   } = useStore();
 
   const editorViewRef = useRef(null);
   const [autoViewed, setAutoViewed] = useState(true);
   const [timelineOpen, setTimelineOpen] = useState(false);
+  // Counter to force editor rebuild on discard
+  const [discardCounter, setDiscardCounter] = useState(0);
 
   // Build scope key for viewed storage
   const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
@@ -99,6 +107,23 @@ export const ChangeReviewDialog = ({
     progress: viewedProgress,
   } = useViewedFiles(teamName, scopeKey, allFilePaths);
 
+  // Editable diff computed values
+  const editedCount = Object.keys(editedContents).length;
+  const hasCurrentFileEdits = !!(
+    selectedReviewFilePath && selectedReviewFilePath in editedContents
+  );
+
+  const handleSaveCurrentFile = useCallback(() => {
+    if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
+  }, [selectedReviewFilePath, saveEditedFile]);
+
+  const handleDiscardCurrentFile = useCallback(() => {
+    if (selectedReviewFilePath) {
+      discardFileEdits(selectedReviewFilePath);
+      setDiscardCounter((c) => c + 1);
+    }
+  }, [selectedReviewFilePath, discardFileEdits]);
+
   const diffNav = useDiffNavigation(
     activeChangeSet?.files ?? [],
     selectedReviewFilePath,
@@ -107,7 +132,8 @@ export const ChangeReviewDialog = ({
     open,
     (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
     (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
-    () => onOpenChange(false)
+    () => onOpenChange(false),
+    handleSaveCurrentFile
   );
 
   // Auto-viewed callback
@@ -147,6 +173,19 @@ export const ChangeReviewDialog = ({
     return () => document.removeEventListener('keydown', handler);
   }, [open, onOpenChange]);
 
+  // Cmd+N IPC listener (forwarded from main process)
+  useEffect(() => {
+    if (!open) return;
+    const cleanup = window.electronAPI?.review.onCmdN?.(() => {
+      const view = editorViewRef.current;
+      if (view) {
+        rejectChunk(view);
+        requestAnimationFrame(() => goToNextChunk(view));
+      }
+    });
+    return cleanup ?? undefined;
+  }, [open]);
+
   // Lazy-load file content when file selected
   useEffect(() => {
     if (!open || !selectedReviewFilePath) return;
@@ -268,6 +307,11 @@ export const ChangeReviewDialog = ({
             onApply={handleApply}
             onDiffViewModeChange={setDiffViewMode}
             onCollapseUnchangedChange={setCollapseUnchanged}
+            editedCount={editedCount}
+            hasCurrentFileEdits={hasCurrentFileEdits}
+            saving={applying}
+            onSaveCurrentFile={handleSaveCurrentFile}
+            onDiscardCurrentFile={handleDiscardCurrentFile}
           />
         )}
 
@@ -399,9 +443,11 @@ export const ChangeReviewDialog = ({
                           newString={fileContent.modifiedFullContent}
                         >
                           
@@ -412,6 +458,9 @@ export const ChangeReviewDialog = ({
                             }
                             onFullyViewed={handleFullyViewed}
                             editorViewRef={editorViewRef}
+                            onContentChanged={(content) => {
+                              updateEditedContent(selectedFile.filePath, content);
+                            }}
                           />
                         
                       
diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
index d409d039..85c97ec5 100644
--- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx
+++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
@@ -1,5 +1,6 @@
 import { useCallback, useEffect, useMemo, useRef } from 'react';
 
+import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
 import { css } from '@codemirror/lang-css';
 import { html } from '@codemirror/lang-html';
 import { javascript } from '@codemirror/lang-javascript';
@@ -32,6 +33,8 @@ interface CodeMirrorDiffViewProps {
   onFullyViewed?: () => void;
   /** Ref to expose the EditorView for external navigation */
   editorViewRef?: React.RefObject;
+  /** Called when editor content changes (debounced, only when readOnly=false) */
+  onContentChanged?: (content: string) => void;
 }
 
 /** Detect language extension from file name */
@@ -183,7 +186,7 @@ export const CodeMirrorDiffView = ({
   modified,
   fileName,
   maxHeight = '100%',
-  readOnly = true,
+  readOnly = false,
   showMergeControls = false,
   collapseUnchanged: collapseUnchangedProp = true,
   collapseMargin = 3,
@@ -191,6 +194,7 @@ export const CodeMirrorDiffView = ({
   onHunkRejected,
   onFullyViewed,
   editorViewRef: externalViewRef,
+  onContentChanged,
 }: CodeMirrorDiffViewProps) => {
   const containerRef = useRef(null);
   const viewRef = useRef(null);
@@ -201,11 +205,21 @@ export const CodeMirrorDiffView = ({
   // Stabilize callbacks via useEffect (cannot update refs during render)
   const onAcceptRef = useRef(onHunkAccepted);
   const onRejectRef = useRef(onHunkRejected);
+  const onContentChangedRef = useRef(onContentChanged);
+  const debounceTimer = useRef>();
   useEffect(() => {
     onAcceptRef.current = onHunkAccepted;
     onRejectRef.current = onHunkRejected;
+    onContentChangedRef.current = onContentChanged;
     externalViewRefHolder.current = externalViewRef;
-  }, [onHunkAccepted, onHunkRejected, externalViewRef]);
+  }, [onHunkAccepted, onHunkRejected, onContentChanged, externalViewRef]);
+
+  // Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate)
+  const scrollToNextChunk = useCallback(() => {
+    requestAnimationFrame(() => {
+      if (viewRef.current) goToNextChunk(viewRef.current);
+    });
+  }, []);
 
   const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]);
 
@@ -216,13 +230,42 @@ export const CodeMirrorDiffView = ({
       EditorState.readOnly.of(readOnly),
     ];
 
+    // Undo/redo support and standard editing keybindings
+    if (!readOnly) {
+      extensions.push(history());
+      extensions.push(keymap.of([...defaultKeymap, ...historyKeymap]));
+    }
+
     if (langExtension) {
       extensions.push(langExtension);
     }
 
-    // Keyboard shortcuts for chunk navigation
+    // Keyboard shortcuts for chunk navigation and accept/reject
     extensions.push(
       keymap.of([
+        {
+          key: 'Mod-y',
+          run: (view) => {
+            acceptChunk(view);
+            requestAnimationFrame(() => goToNextChunk(view));
+            return true;
+          },
+        },
+        {
+          key: 'Mod-n',
+          run: (view) => {
+            rejectChunk(view);
+            requestAnimationFrame(() => goToNextChunk(view));
+            return true;
+          },
+        },
+        {
+          key: 'Alt-j',
+          run: (view) => {
+            goToNextChunk(view);
+            return true;
+          },
+        },
         {
           key: 'Ctrl-Alt-ArrowDown',
           run: goToNextChunk,
@@ -234,6 +277,20 @@ export const CodeMirrorDiffView = ({
       ])
     );
 
+    // Debounced content change listener (only when editable)
+    if (!readOnly) {
+      extensions.push(
+        EditorView.updateListener.of((update) => {
+          if (update.docChanged) {
+            clearTimeout(debounceTimer.current);
+            debounceTimer.current = setTimeout(() => {
+              onContentChangedRef.current?.(update.state.doc.toString());
+            }, 300);
+          }
+        })
+      );
+    }
+
     // Unified merge view
     const mergeConfig: Parameters[0] = {
       original,
@@ -265,6 +322,7 @@ export const CodeMirrorDiffView = ({
               const hunkIndex = computeHunkIndexAtPos(view.state, pos);
               action(e);
               onAcceptRef.current?.(hunkIndex);
+              scrollToNextChunk();
             }
           };
         } else {
@@ -279,6 +337,7 @@ export const CodeMirrorDiffView = ({
               const hunkIndex = computeHunkIndexAtPos(view.state, pos);
               action(e);
               onRejectRef.current?.(hunkIndex);
+              scrollToNextChunk();
             }
           };
         }
@@ -290,7 +349,15 @@ export const CodeMirrorDiffView = ({
     extensions.push(unifiedMergeView(mergeConfig));
 
     return extensions;
-  }, [original, readOnly, langExtension, showMergeControls, collapseUnchangedProp, collapseMargin]);
+  }, [
+    original,
+    readOnly,
+    langExtension,
+    showMergeControls,
+    collapseUnchangedProp,
+    collapseMargin,
+    scrollToNextChunk,
+  ]);
 
   useEffect(() => {
     if (!containerRef.current) return;
diff --git a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
index 22ec7867..cdabccc5 100644
--- a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
+++ b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
@@ -6,13 +6,10 @@ interface KeyboardShortcutsHelpProps {
 }
 
 const shortcuts = [
-  { keys: ['j', '\u2193'], action: 'Next hunk' },
-  { keys: ['k', '\u2191'], action: 'Previous hunk' },
-  { keys: ['n'], action: 'Next file' },
-  { keys: ['p', 'Shift+N'], action: 'Previous file' },
-  { keys: ['a'], action: 'Accept current hunk' },
-  { keys: ['x'], action: 'Reject current hunk' },
-  { keys: ['?'], action: 'Toggle shortcuts help' },
+  { keys: ['\u2325+J'], action: 'Next change' },
+  { keys: ['\u2318+Y'], action: 'Accept change' },
+  { keys: ['\u2318+N'], action: 'Reject change' },
+  { keys: ['\u2318+\u21A9'], action: 'Save file' },
   { keys: ['Esc'], action: 'Close dialog' },
 ];
 
diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx
index 8d171728..070fac30 100644
--- a/src/renderer/components/team/review/ReviewToolbar.tsx
+++ b/src/renderer/components/team/review/ReviewToolbar.tsx
@@ -1,5 +1,17 @@
 import { cn } from '@renderer/lib/utils';
-import { Check, Columns2, Eye, EyeOff, GitMerge, Loader2, Rows2, X } from 'lucide-react';
+import {
+  Check,
+  Columns2,
+  Eye,
+  EyeOff,
+  GitMerge,
+  Loader2,
+  Pencil,
+  Rows2,
+  Save,
+  Undo2,
+  X,
+} from 'lucide-react';
 
 import type { ChangeStats } from '@shared/types';
 
@@ -16,6 +28,12 @@ interface ReviewToolbarProps {
   onApply: () => void;
   onDiffViewModeChange: (mode: 'unified' | 'split') => void;
   onCollapseUnchangedChange: (collapse: boolean) => void;
+  // Editable diff props
+  editedCount?: number;
+  hasCurrentFileEdits?: boolean;
+  saving?: boolean;
+  onSaveCurrentFile?: () => void;
+  onDiscardCurrentFile?: () => void;
 }
 
 export const ReviewToolbar = ({
@@ -31,6 +49,11 @@ export const ReviewToolbar = ({
   onApply,
   onDiffViewModeChange,
   onCollapseUnchangedChange,
+  editedCount = 0,
+  hasCurrentFileEdits = false,
+  saving = false,
+  onSaveCurrentFile,
+  onDiscardCurrentFile,
 }: ReviewToolbarProps) => {
   const hasRejected = stats.rejected > 0;
   const canApply = hasRejected && !applying;
@@ -120,6 +143,35 @@ export const ReviewToolbar = ({
 
       
+ {/* Edited files indicator + actions */} + {hasCurrentFileEdits && ( + <> + + + + )} + {editedCount > 0 && ( + + {editedCount} edited + + )} + + {(hasCurrentFileEdits || editedCount > 0) &&
} + {/* Actions */}