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.
This commit is contained in:
iliya 2026-02-25 09:03:50 +02:00
parent ada5d8359a
commit 1d7e55e89a
19 changed files with 719 additions and 156 deletions

View file

@ -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. Theyll 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`.

View file

@ -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",

View file

@ -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

View file

@ -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();

View file

@ -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<IpcResult<{ success: boolean }>> {
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
return wrapReviewHandler('saveEditedFile', () => getApplier().saveEditedFile(filePath, content));
}
// --- Phase 4 Handlers ---
async function handleGetGitFileLog(

View file

@ -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 ──
/**

View file

@ -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';

View file

@ -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 }[]>(

View file

@ -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');

View file

@ -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<string>;
onItemClick: (itemId: string) => void;
}): React.JSX.Element => (
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
type="button"
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={onToggle}
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
isExpanded && 'rotate-90'
)}
/>
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{group.summary}
</span>
</button>
{isExpanded && (
<div className="border-t border-[var(--color-border)] p-2">
<DisplayItemList
items={group.items}
onItemClick={onItemClick}
expandedItemIds={expandedItemIds}
aiGroupId={group.id}
/>
</div>
)}
</div>
);
export const CliLogsRichView = ({
cliLogsTail,
className,
}: CliLogsRichViewProps): React.JSX.Element => {
const scrollRef = useRef<HTMLDivElement>(null);
// Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
// Derive expanded state: all groups expanded unless manually collapsed
const expandedGroupIds = useMemo(() => {
const expanded = new Set<string>();
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 (
<div
className={cn(
'rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
className
)}
>
{hasContent ? (
<pre className="max-h-[400px] overflow-y-auto p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{cliLogsTail}
</pre>
) : (
<p className="p-3 text-center text-[11px] italic text-[var(--color-text-muted)]">
Waiting for CLI output...
</p>
)}
</div>
);
}
return (
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
{groups.map((group) => (
<StreamGroup
key={group.id}
group={group}
isExpanded={expandedGroupIds.has(group.id)}
onToggle={() => handleGroupToggle(group.id)}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
/>
))}
</div>
);
};

View file

@ -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, `<span style="color:${JSON_KEY_COLOR}">$1</span>:`)
.replace(/:\s*("(?:\\.|[^"\\])*")/g, (match, str: string) =>
match.replace(str, `<span style="color:${JSON_STRING_COLOR}">${str}</span>`)
)
// 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, `<span style="color:${JSON_NUMBER_COLOR}">${num}</span>`)
)
.replace(/:\s*(true|false|null)/g, (match, kw: string) =>
match.replace(kw, `<span style="color:${JSON_BOOL_NULL_COLOR}">${kw}</span>`)
)
.replace(/([{}[\]])/g, `<span style="color:${JSON_BRACKET_COLOR}">$1</span>`)
);
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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<HTMLPreElement>(null);
const outputScrollRef = useRef<HTMLDivElement>(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 ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
CLI logs
</button>
{logsOpen ? (
<pre
ref={logsRef}
className="mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
// Content is HTML-escaped via escapeHtml() before syntax highlighting spans are added
dangerouslySetInnerHTML={{ __html: prettyLogs }}
/>
) : null}
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
</div>
) : null}
</div>

View file

@ -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<EditorView | null>(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}
>
<CodeMirrorDiffView
key={`${selectedFile.filePath}:${discardCounter}`}
original={fileContent.originalFullContent ?? ''}
modified={fileContent.modifiedFullContent}
fileName={selectedFile.relativePath}
readOnly={false}
showMergeControls={true}
collapseUnchanged={collapseUnchanged}
onHunkAccepted={(idx) =>
@ -412,6 +458,9 @@ export const ChangeReviewDialog = ({
}
onFullyViewed={handleFullyViewed}
editorViewRef={editorViewRef}
onContentChanged={(content) => {
updateEditedContent(selectedFile.filePath, content);
}}
/>
</DiffErrorBoundary>
</div>

View file

@ -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<EditorView | null>;
/** 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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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<ReturnType<typeof setTimeout>>();
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<typeof unifiedMergeView>[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;

View file

@ -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' },
];

View file

@ -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 = ({
<div className="h-4 w-px bg-border" />
{/* Edited files indicator + actions */}
{hasCurrentFileEdits && (
<>
<button
onClick={onSaveCurrentFile}
disabled={saving}
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
title="Save file to disk (Cmd+Enter)"
>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Save className="size-3" />}
Save File
</button>
<button
onClick={onDiscardCurrentFile}
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
title="Discard edits for this file"
>
<Undo2 className="size-3" /> Discard
</button>
</>
)}
{editedCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
<Pencil className="size-3" /> {editedCount} edited
</span>
)}
{(hasCurrentFileEdits || editedCount > 0) && <div className="h-4 w-px bg-border" />}
{/* Actions */}
<button
onClick={onAcceptAll}

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary } from '@shared/types/review';
@ -27,7 +27,8 @@ export function useDiffNavigation(
isDialogOpen: boolean,
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
onClose?: () => void
onClose?: () => void,
onSaveFile?: () => void
): DiffNavigationState {
// Track hunk index keyed by file path to auto-reset on file change
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
@ -105,95 +106,67 @@ export function useDiffNavigation(
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
// Store refs for stable closure
const goToNextHunkRef = useRef(goToNextHunk);
const goToPrevHunkRef = useRef(goToPrevHunk);
const goToNextFileRef = useRef(goToNextFile);
const goToPrevFileRef = useRef(goToPrevFile);
const acceptCurrentHunkRef = useRef(acceptCurrentHunk);
const rejectCurrentHunkRef = useRef(rejectCurrentHunk);
const onCloseRef = useRef(onClose);
const onSaveFileRef = useRef(onSaveFile);
useEffect(() => {
goToNextHunkRef.current = goToNextHunk;
goToPrevHunkRef.current = goToPrevHunk;
goToNextFileRef.current = goToNextFile;
goToPrevFileRef.current = goToPrevFile;
acceptCurrentHunkRef.current = acceptCurrentHunk;
rejectCurrentHunkRef.current = rejectCurrentHunk;
onCloseRef.current = onClose;
}, [
goToNextHunk,
goToPrevHunk,
goToNextFile,
goToPrevFile,
acceptCurrentHunk,
rejectCurrentHunk,
onClose,
]);
onSaveFileRef.current = onSaveFile;
}, [onClose, onSaveFile]);
// Keyboard handler
// Keyboard handler — new shortcuts for editable diff
useEffect(() => {
if (!isDialogOpen) return;
const handler = (event: KeyboardEvent) => {
// Don't intercept when focus is in input/textarea
// Skip if CM keymap already handled this event
if (event.defaultPrevented) return;
// Skip inputs/textareas
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
switch (event.key) {
case 'j':
case 'ArrowDown':
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
goToNextHunkRef.current();
}
break;
case 'k':
case 'ArrowUp':
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
goToPrevHunkRef.current();
}
break;
case 'n':
if (!event.shiftKey) {
event.preventDefault();
goToNextFileRef.current();
} else {
event.preventDefault();
goToPrevFileRef.current();
}
break;
case 'p':
const isMeta = event.metaKey || event.ctrlKey;
// Alt+J -> next change
if (event.altKey && event.key.toLowerCase() === 'j') {
event.preventDefault();
const view = editorViewRef.current;
if (view) goToNextChunk(view);
return;
}
// Cmd+Enter -> save file
if (isMeta && event.key === 'Enter') {
event.preventDefault();
onSaveFileRef.current?.();
return;
}
// Cmd+Y -> accept + scroll (fallback when editor not focused)
if (isMeta && event.key.toLowerCase() === 'y') {
event.preventDefault();
const view = editorViewRef.current;
if (view) {
acceptChunk(view);
requestAnimationFrame(() => goToNextChunk(view));
}
return;
}
// Escape handling
if (event.key === 'Escape') {
if (showShortcutsHelp) {
event.preventDefault();
goToPrevFileRef.current();
break;
case 'a':
event.preventDefault();
acceptCurrentHunkRef.current();
break;
case 'x':
event.preventDefault();
rejectCurrentHunkRef.current();
break;
case '?':
event.preventDefault();
setShowShortcutsHelp((prev) => !prev);
break;
case 'Escape':
if (showShortcutsHelp) {
event.preventDefault();
setShowShortcutsHelp(false);
}
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
break;
setShowShortcutsHelp(false);
}
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isDialogOpen, showShortcutsHelp]);
}, [isDialogOpen, showShortcutsHelp, editorViewRef]);
return {
currentHunkIndex,

View file

@ -42,6 +42,9 @@ export interface ChangeReviewSlice {
applyError: string | null;
applying: boolean;
// Editable diff state
editedContents: Record<string, string>;
// Phase 1 actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
@ -65,6 +68,12 @@ export interface ChangeReviewSlice {
) => Promise<void>;
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
invalidateChangeStats: (teamName: string) => void;
// Editable diff actions
updateEditedContent: (filePath: string, content: string) => void;
discardFileEdits: (filePath: string) => void;
discardAllEdits: () => void;
saveEditedFile: (filePath: string) => Promise<void>;
}
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
@ -88,6 +97,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
applyError: null,
applying: false,
// Editable diff initial state
editedContents: {},
fetchAgentChanges: async (teamName: string, memberName: string) => {
set({ changeSetLoading: true, changeSetError: null });
try {
@ -136,6 +148,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileContentsLoading: {},
applyError: null,
applying: false,
editedContents: {},
});
},
@ -350,6 +363,40 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
}
},
// ── Editable diff actions ──
updateEditedContent: (filePath: string, content: string) => {
set((s) => ({
editedContents: { ...s.editedContents, [filePath]: content },
}));
},
discardFileEdits: (filePath: string) => {
set((s) => {
const next = { ...s.editedContents };
delete next[filePath];
return { editedContents: next };
});
},
discardAllEdits: () => set({ editedContents: {} }),
saveEditedFile: async (filePath: string) => {
const content = get().editedContents[filePath];
if (!(filePath in get().editedContents)) return;
set({ applying: true, applyError: null });
try {
await api.review.saveEditedFile(filePath, content);
set((s) => {
const next = { ...s.editedContents };
delete next[filePath];
return { editedContents: next, applying: false };
});
} catch (error) {
set({ applying: false, applyError: mapReviewError(error) });
}
},
invalidateChangeStats: (teamName: string) => {
set((state) => {
const newCache = { ...state.changeStatsCache };

View file

@ -0,0 +1,214 @@
/**
* Stream-JSON Parser
*
* Parses CLI stream-json stdout lines into AIGroupDisplayItem[] for rich rendering.
* Used by CliLogsRichView to replace raw JSON display with beautiful components.
*/
import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers';
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
/**
* A group of display items from one or more consecutive assistant messages.
*/
export interface StreamJsonGroup {
/** Unique group ID */
id: string;
/** Display items within this group */
items: AIGroupDisplayItem[];
/** Human-readable summary (e.g. "1 thinking, 2 tool calls") */
summary: string;
/** Timestamp of first message in group */
timestamp: Date;
}
interface ContentBlock {
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
}
/**
* Attempts to extract the content array from a parsed stream-json line.
* Handles both `{ type: "assistant", content: [...] }` and
* `{ message: { type: "assistant", content: [...] } }` formats.
*/
function extractContentBlocks(parsed: unknown): ContentBlock[] | null {
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
// Direct format: { type: "assistant", content: [...] }
if (obj.type === 'assistant' && Array.isArray(obj.content)) {
return obj.content as ContentBlock[];
}
// Wrapped format: { message: { type: "assistant", content: [...] } }
if (obj.message && typeof obj.message === 'object') {
const msg = obj.message as Record<string, unknown>;
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
return msg.content as ContentBlock[];
}
}
// Result format: { type: "result", result: { type: "assistant", content: [...] } }
if (obj.type === 'result' && obj.result && typeof obj.result === 'object') {
const result = obj.result as Record<string, unknown>;
if (Array.isArray(result.content)) {
return result.content as ContentBlock[];
}
}
return null;
}
/**
* Converts content blocks from a single assistant message into display items.
* @param lineIndex - stable line position for deterministic fallback IDs
*/
function contentBlocksToDisplayItems(
blocks: ContentBlock[],
timestamp: Date,
lineIndex: number
): AIGroupDisplayItem[] {
const items: AIGroupDisplayItem[] = [];
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
const block = blocks[blockIdx];
switch (block.type) {
case 'thinking': {
const text = block.thinking ?? '';
if (text.trim()) {
items.push({ type: 'thinking', content: text, timestamp });
}
break;
}
case 'text': {
const text = block.text ?? '';
if (text.trim()) {
items.push({ type: 'output', content: text, timestamp });
}
break;
}
case 'tool_use': {
const input = block.input ?? {};
const toolName = block.name ?? 'Unknown';
const linkedTool: LinkedToolItem = {
id: block.id ?? `stream-tool-L${lineIndex}-B${blockIdx}`,
name: toolName,
input,
inputPreview: getToolSummary(toolName, input),
startTime: timestamp,
isOrphaned: true,
};
items.push({ type: 'tool', tool: linkedTool });
break;
}
}
}
return items;
}
/**
* Builds a human-readable summary string from display items.
*/
function buildGroupSummary(items: AIGroupDisplayItem[]): string {
let thinkingCount = 0;
let toolCount = 0;
let outputCount = 0;
for (const item of items) {
switch (item.type) {
case 'thinking':
thinkingCount++;
break;
case 'tool':
toolCount++;
break;
case 'output':
outputCount++;
break;
}
}
const parts: string[] = [];
if (thinkingCount > 0) parts.push(`${thinkingCount} thinking`);
if (toolCount > 0) parts.push(`${toolCount} tool call${toolCount > 1 ? 's' : ''}`);
if (outputCount > 0) parts.push(`${outputCount} output${outputCount > 1 ? 's' : ''}`);
return parts.join(', ') || 'empty';
}
/**
* Parses stream-json CLI output lines into structured groups for rich rendering.
*
* Each group represents one or more consecutive assistant messages.
* Non-assistant lines (markers, errors, etc.) are silently skipped.
*/
export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] {
if (!cliLogsTail.trim()) return [];
const lines = cliLogsTail.split('\n');
const groups: StreamJsonGroup[] = [];
let currentItems: AIGroupDisplayItem[] = [];
let currentTimestamp: Date | null = null;
let groupCounter = 0;
// Stable timestamp for the entire parse (deterministic across re-renders)
const parseTimestamp = new Date();
const flushGroup = (): void => {
if (currentItems.length > 0 && currentTimestamp) {
groups.push({
id: `stream-group-${groupCounter++}`,
items: currentItems,
summary: buildGroupSummary(currentItems),
timestamp: currentTimestamp,
});
currentItems = [];
currentTimestamp = null;
}
};
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const trimmed = lines[lineIndex].trim();
// Skip empty lines and stream markers
if (!trimmed || trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) {
continue;
}
// Try to parse as JSON
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
// Non-JSON line (truncated, marker, etc.) — flush and skip
flushGroup();
continue;
}
const blocks = extractContentBlocks(parsed);
if (!blocks) {
// Valid JSON but not an assistant message — flush and skip
flushGroup();
continue;
}
if (!currentTimestamp) currentTimestamp = parseTimestamp;
const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex);
currentItems.push(...items);
}
// Flush remaining items
flushGroup();
return groups;
}

View file

@ -463,6 +463,9 @@ export interface ReviewAPI {
hunkIndices: number[],
snippets: SnippetDiff[]
) => Promise<{ preview: string; hasConflicts: boolean }>;
// Editable diff
saveEditedFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
onCmdN?: (callback: () => void) => (() => void) | undefined;
// Phase 4
getGitFileLog: (
projectPath: string,