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:
parent
ada5d8359a
commit
1d7e55e89a
19 changed files with 719 additions and 156 deletions
|
|
@ -6,6 +6,7 @@ This project follows the Contributor Covenant Code of Conduct.
|
||||||
- Be respectful and constructive.
|
- Be respectful and constructive.
|
||||||
- Assume good intent and discuss ideas, not people.
|
- Assume good intent and discuss ideas, not people.
|
||||||
- Give actionable feedback and accept feedback gracefully.
|
- 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
|
## Unacceptable Behavior
|
||||||
- Harassment, discrimination, or personal attacks.
|
- Harassment, discrimination, or personal attacks.
|
||||||
|
|
@ -13,7 +14,7 @@ This project follows the Contributor Covenant Code of Conduct.
|
||||||
- Publishing private information without explicit permission.
|
- Publishing private information without explicit permission.
|
||||||
|
|
||||||
## Enforcement
|
## 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
|
## Reporting
|
||||||
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`.
|
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`.
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.10.2",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@codemirror/commands':
|
||||||
|
specifier: ^6.10.2
|
||||||
|
version: 6.10.2
|
||||||
'@codemirror/lang-css':
|
'@codemirror/lang-css':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
version: 6.3.1
|
version: 6.3.1
|
||||||
|
|
@ -396,6 +399,9 @@ packages:
|
||||||
'@codemirror/autocomplete@6.20.0':
|
'@codemirror/autocomplete@6.20.0':
|
||||||
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
|
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
|
||||||
|
|
||||||
|
'@codemirror/commands@6.10.2':
|
||||||
|
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
||||||
|
|
||||||
'@codemirror/lang-css@6.3.1':
|
'@codemirror/lang-css@6.3.1':
|
||||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||||
|
|
||||||
|
|
@ -5562,6 +5568,13 @@ snapshots:
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.1
|
'@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':
|
'@codemirror/lang-css@6.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,13 @@ function createWindow(): void {
|
||||||
return;
|
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;
|
if (!input.meta) return;
|
||||||
|
|
||||||
const currentLevel = mainWindow.webContents.getZoomLevel();
|
const currentLevel = mainWindow.webContents.getZoomLevel();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
REVIEW_PREVIEW_REJECT,
|
REVIEW_PREVIEW_REJECT,
|
||||||
REVIEW_REJECT_FILE,
|
REVIEW_REJECT_FILE,
|
||||||
REVIEW_REJECT_HUNKS,
|
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
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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_PREVIEW_REJECT, handlePreviewReject);
|
||||||
ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions);
|
ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions);
|
||||||
ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
|
ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
|
||||||
|
// Editable diff
|
||||||
|
ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile);
|
||||||
// Phase 4
|
// Phase 4
|
||||||
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
|
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_PREVIEW_REJECT);
|
||||||
ipcMain.removeHandler(REVIEW_APPLY_DECISIONS);
|
ipcMain.removeHandler(REVIEW_APPLY_DECISIONS);
|
||||||
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
|
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
|
||||||
|
// Editable diff
|
||||||
|
ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE);
|
||||||
// Phase 4
|
// Phase 4
|
||||||
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
|
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 ---
|
// --- Phase 4 Handlers ---
|
||||||
|
|
||||||
async function handleGetGitFileLog(
|
async function handleGetGitFileLog(
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,14 @@ export class ReviewApplierService {
|
||||||
return { applied, skipped, conflicts, errors };
|
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 ──
|
// ── Private: Rejection strategies ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -320,5 +320,8 @@ export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent';
|
||||||
|
|
||||||
// Phase 4 — Git fallback
|
// Phase 4 — Git fallback
|
||||||
|
|
||||||
|
/** Save edited file content to disk */
|
||||||
|
export const REVIEW_SAVE_EDITED_FILE = 'review:saveEditedFile';
|
||||||
|
|
||||||
/** Get git file change log */
|
/** Get git file change log */
|
||||||
export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog';
|
export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog';
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
REVIEW_PREVIEW_REJECT,
|
REVIEW_PREVIEW_REJECT,
|
||||||
REVIEW_REJECT_FILE,
|
REVIEW_REJECT_FILE,
|
||||||
REVIEW_REJECT_HUNKS,
|
REVIEW_REJECT_HUNKS,
|
||||||
|
REVIEW_SAVE_EDITED_FILE,
|
||||||
SSH_CONNECT,
|
SSH_CONNECT,
|
||||||
SSH_DISCONNECT,
|
SSH_DISCONNECT,
|
||||||
SSH_GET_CONFIG_HOSTS,
|
SSH_GET_CONFIG_HOSTS,
|
||||||
|
|
@ -755,6 +756,17 @@ const electronAPI: ElectronAPI = {
|
||||||
snippets
|
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
|
// Phase 4
|
||||||
getGitFileLog: async (projectPath: string, filePath: string) => {
|
getGitFileLog: async (projectPath: string, filePath: string) => {
|
||||||
return invokeIpcWithResult<{ hash: string; timestamp: string; message: string }[]>(
|
return invokeIpcWithResult<{ hash: string; timestamp: string; message: string }[]>(
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
previewReject: async () => {
|
previewReject: async () => {
|
||||||
throw new Error('Review is not available in browser mode');
|
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
|
// Phase 4 stubs
|
||||||
getGitFileLog: async () => {
|
getGitFileLog: async () => {
|
||||||
throw new Error('Review is not available in browser mode');
|
throw new Error('Review is not available in browser mode');
|
||||||
|
|
|
||||||
161
src/renderer/components/team/CliLogsRichView.tsx
Normal file
161
src/renderer/components/team/CliLogsRichView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
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 { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
|
||||||
|
|
||||||
|
import { CliLogsRichView } from './CliLogsRichView';
|
||||||
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
||||||
|
|
||||||
import type { ProvisioningStep } 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, '&').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 {
|
export interface ProvisioningProgressBlockProps {
|
||||||
/** Title above the steps, e.g. "Launching team" */
|
/** Title above the steps, e.g. "Launching team" */
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -125,19 +77,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||||
const elapsed = useElapsedTimer(startedAt);
|
const elapsed = useElapsedTimer(startedAt);
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
const logsRef = useRef<HTMLPreElement>(null);
|
|
||||||
const outputScrollRef = useRef<HTMLDivElement>(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
|
// Auto-scroll assistant output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -231,14 +171,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
CLI logs
|
CLI logs
|
||||||
</button>
|
</button>
|
||||||
{logsOpen ? (
|
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
||||||
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -74,11 +75,18 @@ export const ChangeReviewDialog = ({
|
||||||
acceptAll,
|
acceptAll,
|
||||||
rejectAll,
|
rejectAll,
|
||||||
applyReview,
|
applyReview,
|
||||||
|
// Editable diff
|
||||||
|
editedContents,
|
||||||
|
updateEditedContent,
|
||||||
|
discardFileEdits,
|
||||||
|
saveEditedFile,
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
const editorViewRef = useRef<EditorView | null>(null);
|
const editorViewRef = useRef<EditorView | null>(null);
|
||||||
const [autoViewed, setAutoViewed] = useState(true);
|
const [autoViewed, setAutoViewed] = useState(true);
|
||||||
const [timelineOpen, setTimelineOpen] = useState(false);
|
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||||
|
// Counter to force editor rebuild on discard
|
||||||
|
const [discardCounter, setDiscardCounter] = useState(0);
|
||||||
|
|
||||||
// Build scope key for viewed storage
|
// Build scope key for viewed storage
|
||||||
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
||||||
|
|
@ -99,6 +107,23 @@ export const ChangeReviewDialog = ({
|
||||||
progress: viewedProgress,
|
progress: viewedProgress,
|
||||||
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
} = 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(
|
const diffNav = useDiffNavigation(
|
||||||
activeChangeSet?.files ?? [],
|
activeChangeSet?.files ?? [],
|
||||||
selectedReviewFilePath,
|
selectedReviewFilePath,
|
||||||
|
|
@ -107,7 +132,8 @@ export const ChangeReviewDialog = ({
|
||||||
open,
|
open,
|
||||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
|
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
|
||||||
() => onOpenChange(false)
|
() => onOpenChange(false),
|
||||||
|
handleSaveCurrentFile
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-viewed callback
|
// Auto-viewed callback
|
||||||
|
|
@ -147,6 +173,19 @@ export const ChangeReviewDialog = ({
|
||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, [open, onOpenChange]);
|
}, [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
|
// Lazy-load file content when file selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedReviewFilePath) return;
|
if (!open || !selectedReviewFilePath) return;
|
||||||
|
|
@ -268,6 +307,11 @@ export const ChangeReviewDialog = ({
|
||||||
onApply={handleApply}
|
onApply={handleApply}
|
||||||
onDiffViewModeChange={setDiffViewMode}
|
onDiffViewModeChange={setDiffViewMode}
|
||||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||||
|
editedCount={editedCount}
|
||||||
|
hasCurrentFileEdits={hasCurrentFileEdits}
|
||||||
|
saving={applying}
|
||||||
|
onSaveCurrentFile={handleSaveCurrentFile}
|
||||||
|
onDiscardCurrentFile={handleDiscardCurrentFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -399,9 +443,11 @@ export const ChangeReviewDialog = ({
|
||||||
newString={fileContent.modifiedFullContent}
|
newString={fileContent.modifiedFullContent}
|
||||||
>
|
>
|
||||||
<CodeMirrorDiffView
|
<CodeMirrorDiffView
|
||||||
|
key={`${selectedFile.filePath}:${discardCounter}`}
|
||||||
original={fileContent.originalFullContent ?? ''}
|
original={fileContent.originalFullContent ?? ''}
|
||||||
modified={fileContent.modifiedFullContent}
|
modified={fileContent.modifiedFullContent}
|
||||||
fileName={selectedFile.relativePath}
|
fileName={selectedFile.relativePath}
|
||||||
|
readOnly={false}
|
||||||
showMergeControls={true}
|
showMergeControls={true}
|
||||||
collapseUnchanged={collapseUnchanged}
|
collapseUnchanged={collapseUnchanged}
|
||||||
onHunkAccepted={(idx) =>
|
onHunkAccepted={(idx) =>
|
||||||
|
|
@ -412,6 +458,9 @@ export const ChangeReviewDialog = ({
|
||||||
}
|
}
|
||||||
onFullyViewed={handleFullyViewed}
|
onFullyViewed={handleFullyViewed}
|
||||||
editorViewRef={editorViewRef}
|
editorViewRef={editorViewRef}
|
||||||
|
onContentChanged={(content) => {
|
||||||
|
updateEditedContent(selectedFile.filePath, content);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</DiffErrorBoundary>
|
</DiffErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||||
import { css } from '@codemirror/lang-css';
|
import { css } from '@codemirror/lang-css';
|
||||||
import { html } from '@codemirror/lang-html';
|
import { html } from '@codemirror/lang-html';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
|
@ -32,6 +33,8 @@ interface CodeMirrorDiffViewProps {
|
||||||
onFullyViewed?: () => void;
|
onFullyViewed?: () => void;
|
||||||
/** Ref to expose the EditorView for external navigation */
|
/** Ref to expose the EditorView for external navigation */
|
||||||
editorViewRef?: React.RefObject<EditorView | null>;
|
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 */
|
/** Detect language extension from file name */
|
||||||
|
|
@ -183,7 +186,7 @@ export const CodeMirrorDiffView = ({
|
||||||
modified,
|
modified,
|
||||||
fileName,
|
fileName,
|
||||||
maxHeight = '100%',
|
maxHeight = '100%',
|
||||||
readOnly = true,
|
readOnly = false,
|
||||||
showMergeControls = false,
|
showMergeControls = false,
|
||||||
collapseUnchanged: collapseUnchangedProp = true,
|
collapseUnchanged: collapseUnchangedProp = true,
|
||||||
collapseMargin = 3,
|
collapseMargin = 3,
|
||||||
|
|
@ -191,6 +194,7 @@ export const CodeMirrorDiffView = ({
|
||||||
onHunkRejected,
|
onHunkRejected,
|
||||||
onFullyViewed,
|
onFullyViewed,
|
||||||
editorViewRef: externalViewRef,
|
editorViewRef: externalViewRef,
|
||||||
|
onContentChanged,
|
||||||
}: CodeMirrorDiffViewProps) => {
|
}: CodeMirrorDiffViewProps) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
@ -201,11 +205,21 @@ export const CodeMirrorDiffView = ({
|
||||||
// Stabilize callbacks via useEffect (cannot update refs during render)
|
// Stabilize callbacks via useEffect (cannot update refs during render)
|
||||||
const onAcceptRef = useRef(onHunkAccepted);
|
const onAcceptRef = useRef(onHunkAccepted);
|
||||||
const onRejectRef = useRef(onHunkRejected);
|
const onRejectRef = useRef(onHunkRejected);
|
||||||
|
const onContentChangedRef = useRef(onContentChanged);
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onAcceptRef.current = onHunkAccepted;
|
onAcceptRef.current = onHunkAccepted;
|
||||||
onRejectRef.current = onHunkRejected;
|
onRejectRef.current = onHunkRejected;
|
||||||
|
onContentChangedRef.current = onContentChanged;
|
||||||
externalViewRefHolder.current = externalViewRef;
|
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]);
|
const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]);
|
||||||
|
|
||||||
|
|
@ -216,13 +230,42 @@ export const CodeMirrorDiffView = ({
|
||||||
EditorState.readOnly.of(readOnly),
|
EditorState.readOnly.of(readOnly),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Undo/redo support and standard editing keybindings
|
||||||
|
if (!readOnly) {
|
||||||
|
extensions.push(history());
|
||||||
|
extensions.push(keymap.of([...defaultKeymap, ...historyKeymap]));
|
||||||
|
}
|
||||||
|
|
||||||
if (langExtension) {
|
if (langExtension) {
|
||||||
extensions.push(langExtension);
|
extensions.push(langExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts for chunk navigation
|
// Keyboard shortcuts for chunk navigation and accept/reject
|
||||||
extensions.push(
|
extensions.push(
|
||||||
keymap.of([
|
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',
|
key: 'Ctrl-Alt-ArrowDown',
|
||||||
run: goToNextChunk,
|
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
|
// Unified merge view
|
||||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
||||||
original,
|
original,
|
||||||
|
|
@ -265,6 +322,7 @@ export const CodeMirrorDiffView = ({
|
||||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||||
action(e);
|
action(e);
|
||||||
onAcceptRef.current?.(hunkIndex);
|
onAcceptRef.current?.(hunkIndex);
|
||||||
|
scrollToNextChunk();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -279,6 +337,7 @@ export const CodeMirrorDiffView = ({
|
||||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||||
action(e);
|
action(e);
|
||||||
onRejectRef.current?.(hunkIndex);
|
onRejectRef.current?.(hunkIndex);
|
||||||
|
scrollToNextChunk();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +349,15 @@ export const CodeMirrorDiffView = ({
|
||||||
extensions.push(unifiedMergeView(mergeConfig));
|
extensions.push(unifiedMergeView(mergeConfig));
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
}, [original, readOnly, langExtension, showMergeControls, collapseUnchangedProp, collapseMargin]);
|
}, [
|
||||||
|
original,
|
||||||
|
readOnly,
|
||||||
|
langExtension,
|
||||||
|
showMergeControls,
|
||||||
|
collapseUnchangedProp,
|
||||||
|
collapseMargin,
|
||||||
|
scrollToNextChunk,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,10 @@ interface KeyboardShortcutsHelpProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
{ keys: ['j', '\u2193'], action: 'Next hunk' },
|
{ keys: ['\u2325+J'], action: 'Next change' },
|
||||||
{ keys: ['k', '\u2191'], action: 'Previous hunk' },
|
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
||||||
{ keys: ['n'], action: 'Next file' },
|
{ keys: ['\u2318+N'], action: 'Reject change' },
|
||||||
{ keys: ['p', 'Shift+N'], action: 'Previous file' },
|
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
||||||
{ keys: ['a'], action: 'Accept current hunk' },
|
|
||||||
{ keys: ['x'], action: 'Reject current hunk' },
|
|
||||||
{ keys: ['?'], action: 'Toggle shortcuts help' },
|
|
||||||
{ keys: ['Esc'], action: 'Close dialog' },
|
{ keys: ['Esc'], action: 'Close dialog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import { cn } from '@renderer/lib/utils';
|
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';
|
import type { ChangeStats } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -16,6 +28,12 @@ interface ReviewToolbarProps {
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
|
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
|
||||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||||
|
// Editable diff props
|
||||||
|
editedCount?: number;
|
||||||
|
hasCurrentFileEdits?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onSaveCurrentFile?: () => void;
|
||||||
|
onDiscardCurrentFile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReviewToolbar = ({
|
export const ReviewToolbar = ({
|
||||||
|
|
@ -31,6 +49,11 @@ export const ReviewToolbar = ({
|
||||||
onApply,
|
onApply,
|
||||||
onDiffViewModeChange,
|
onDiffViewModeChange,
|
||||||
onCollapseUnchangedChange,
|
onCollapseUnchangedChange,
|
||||||
|
editedCount = 0,
|
||||||
|
hasCurrentFileEdits = false,
|
||||||
|
saving = false,
|
||||||
|
onSaveCurrentFile,
|
||||||
|
onDiscardCurrentFile,
|
||||||
}: ReviewToolbarProps) => {
|
}: ReviewToolbarProps) => {
|
||||||
const hasRejected = stats.rejected > 0;
|
const hasRejected = stats.rejected > 0;
|
||||||
const canApply = hasRejected && !applying;
|
const canApply = hasRejected && !applying;
|
||||||
|
|
@ -120,6 +143,35 @@ export const ReviewToolbar = ({
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border" />
|
<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 */}
|
{/* Actions */}
|
||||||
<button
|
<button
|
||||||
onClick={onAcceptAll}
|
onClick={onAcceptAll}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { EditorView } from '@codemirror/view';
|
||||||
import type { FileChangeSummary } from '@shared/types/review';
|
import type { FileChangeSummary } from '@shared/types/review';
|
||||||
|
|
@ -27,7 +27,8 @@ export function useDiffNavigation(
|
||||||
isDialogOpen: boolean,
|
isDialogOpen: boolean,
|
||||||
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
|
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
|
||||||
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
||||||
onClose?: () => void
|
onClose?: () => void,
|
||||||
|
onSaveFile?: () => void
|
||||||
): DiffNavigationState {
|
): DiffNavigationState {
|
||||||
// Track hunk index keyed by file path to auto-reset on file change
|
// Track hunk index keyed by file path to auto-reset on file change
|
||||||
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
|
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
|
||||||
|
|
@ -105,95 +106,67 @@ export function useDiffNavigation(
|
||||||
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
|
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
|
||||||
|
|
||||||
// Store refs for stable closure
|
// 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 onCloseRef = useRef(onClose);
|
||||||
|
const onSaveFileRef = useRef(onSaveFile);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
goToNextHunkRef.current = goToNextHunk;
|
|
||||||
goToPrevHunkRef.current = goToPrevHunk;
|
|
||||||
goToNextFileRef.current = goToNextFile;
|
|
||||||
goToPrevFileRef.current = goToPrevFile;
|
|
||||||
acceptCurrentHunkRef.current = acceptCurrentHunk;
|
|
||||||
rejectCurrentHunkRef.current = rejectCurrentHunk;
|
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
}, [
|
onSaveFileRef.current = onSaveFile;
|
||||||
goToNextHunk,
|
}, [onClose, onSaveFile]);
|
||||||
goToPrevHunk,
|
|
||||||
goToNextFile,
|
|
||||||
goToPrevFile,
|
|
||||||
acceptCurrentHunk,
|
|
||||||
rejectCurrentHunk,
|
|
||||||
onClose,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Keyboard handler
|
// Keyboard handler — new shortcuts for editable diff
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDialogOpen) return;
|
if (!isDialogOpen) return;
|
||||||
|
|
||||||
const handler = (event: KeyboardEvent) => {
|
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) {
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (event.key) {
|
const isMeta = event.metaKey || event.ctrlKey;
|
||||||
case 'j':
|
|
||||||
case 'ArrowDown':
|
// Alt+J -> next change
|
||||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
if (event.altKey && event.key.toLowerCase() === 'j') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goToNextHunkRef.current();
|
const view = editorViewRef.current;
|
||||||
}
|
if (view) goToNextChunk(view);
|
||||||
break;
|
return;
|
||||||
case 'k':
|
}
|
||||||
case 'ArrowUp':
|
|
||||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
// Cmd+Enter -> save file
|
||||||
event.preventDefault();
|
if (isMeta && event.key === 'Enter') {
|
||||||
goToPrevHunkRef.current();
|
event.preventDefault();
|
||||||
}
|
onSaveFileRef.current?.();
|
||||||
break;
|
return;
|
||||||
case 'n':
|
}
|
||||||
if (!event.shiftKey) {
|
|
||||||
event.preventDefault();
|
// Cmd+Y -> accept + scroll (fallback when editor not focused)
|
||||||
goToNextFileRef.current();
|
if (isMeta && event.key.toLowerCase() === 'y') {
|
||||||
} else {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
const view = editorViewRef.current;
|
||||||
goToPrevFileRef.current();
|
if (view) {
|
||||||
}
|
acceptChunk(view);
|
||||||
break;
|
requestAnimationFrame(() => goToNextChunk(view));
|
||||||
case 'p':
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape handling
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (showShortcutsHelp) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goToPrevFileRef.current();
|
setShowShortcutsHelp(false);
|
||||||
break;
|
}
|
||||||
case 'a':
|
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handler);
|
document.addEventListener('keydown', handler);
|
||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, [isDialogOpen, showShortcutsHelp]);
|
}, [isDialogOpen, showShortcutsHelp, editorViewRef]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentHunkIndex,
|
currentHunkIndex,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ export interface ChangeReviewSlice {
|
||||||
applyError: string | null;
|
applyError: string | null;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
|
|
||||||
|
// Editable diff state
|
||||||
|
editedContents: Record<string, string>;
|
||||||
|
|
||||||
// Phase 1 actions
|
// Phase 1 actions
|
||||||
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
||||||
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||||
|
|
@ -65,6 +68,12 @@ export interface ChangeReviewSlice {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
|
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
|
||||||
invalidateChangeStats: (teamName: string) => 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> = (
|
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||||
|
|
@ -88,6 +97,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
applyError: null,
|
applyError: null,
|
||||||
applying: false,
|
applying: false,
|
||||||
|
|
||||||
|
// Editable diff initial state
|
||||||
|
editedContents: {},
|
||||||
|
|
||||||
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
||||||
set({ changeSetLoading: true, changeSetError: null });
|
set({ changeSetLoading: true, changeSetError: null });
|
||||||
try {
|
try {
|
||||||
|
|
@ -136,6 +148,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
fileContentsLoading: {},
|
fileContentsLoading: {},
|
||||||
applyError: null,
|
applyError: null,
|
||||||
applying: false,
|
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) => {
|
invalidateChangeStats: (teamName: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newCache = { ...state.changeStatsCache };
|
const newCache = { ...state.changeStatsCache };
|
||||||
|
|
|
||||||
214
src/renderer/utils/streamJsonParser.ts
Normal file
214
src/renderer/utils/streamJsonParser.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -463,6 +463,9 @@ export interface ReviewAPI {
|
||||||
hunkIndices: number[],
|
hunkIndices: number[],
|
||||||
snippets: SnippetDiff[]
|
snippets: SnippetDiff[]
|
||||||
) => Promise<{ preview: string; hasConflicts: boolean }>;
|
) => Promise<{ preview: string; hasConflicts: boolean }>;
|
||||||
|
// Editable diff
|
||||||
|
saveEditedFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
|
||||||
|
onCmdN?: (callback: () => void) => (() => void) | undefined;
|
||||||
// Phase 4
|
// Phase 4
|
||||||
getGitFileLog: (
|
getGitFileLog: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue