fix(graph): toggle buttons visually distinct — active gets border + glow, inactive dimmed
This commit is contained in:
parent
b7064759c0
commit
8a9121fc3e
6 changed files with 309 additions and 101 deletions
|
|
@ -178,10 +178,10 @@ function ToolbarToggle({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors cursor-pointer
|
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-mono transition-all cursor-pointer border
|
||||||
${active
|
${active
|
||||||
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.12)]'
|
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.18)] border-[rgba(100,200,255,0.3)] shadow-[0_0_6px_rgba(100,200,255,0.15)]'
|
||||||
: 'text-[#66ccff50] hover:text-[#66ccff90]'
|
: 'text-[#66ccff40] bg-transparent border-transparent hover:text-[#66ccff70] hover:border-[rgba(100,200,255,0.1)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
import {
|
||||||
|
buildTaskChangeRequestOptions,
|
||||||
|
buildTaskChangeSignature,
|
||||||
|
deriveTaskSince,
|
||||||
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
|
@ -78,6 +82,8 @@ import {
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const TASK_CHANGES_AUTO_REFRESH_MS = 20_000;
|
||||||
|
|
||||||
import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments';
|
import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments';
|
||||||
|
|
||||||
import { WorkflowTimeline } from './StatusHistoryTimeline';
|
import { WorkflowTimeline } from './StatusHistoryTimeline';
|
||||||
|
|
@ -154,6 +160,9 @@ export const TaskDetailDialog = ({
|
||||||
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
||||||
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
||||||
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
|
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
|
||||||
|
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
|
||||||
|
const taskChangesLoadInFlightRef = useRef(false);
|
||||||
|
const currentTaskChangeSummaryKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Inline editing: subject
|
// Inline editing: subject
|
||||||
const [editingSubject, setEditingSubject] = useState(false);
|
const [editingSubject, setEditingSubject] = useState(false);
|
||||||
|
|
@ -323,6 +332,17 @@ export const TaskDetailDialog = ({
|
||||||
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
|
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
|
||||||
[currentTask]
|
[currentTask]
|
||||||
);
|
);
|
||||||
|
const taskChangeRequestSignature = useMemo(
|
||||||
|
() => (taskChangeRequestOptions ? buildTaskChangeSignature(taskChangeRequestOptions) : null),
|
||||||
|
[taskChangeRequestOptions]
|
||||||
|
);
|
||||||
|
const currentTaskChangeSummaryKey = useMemo(
|
||||||
|
() =>
|
||||||
|
currentTask
|
||||||
|
? `${teamName}:${currentTask.id}:${taskChangeRequestSignature ?? 'default'}`
|
||||||
|
: null,
|
||||||
|
[currentTask, teamName, taskChangeRequestSignature]
|
||||||
|
);
|
||||||
const taskChangeSummaryOptions = useMemo(
|
const taskChangeSummaryOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
currentTask
|
currentTask
|
||||||
|
|
@ -335,6 +355,10 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentTaskChangeSummaryKeyRef.current = currentTaskChangeSummaryKey;
|
||||||
|
}, [currentTaskChangeSummaryKey]);
|
||||||
|
|
||||||
const loadTaskChangeSummary = useCallback(
|
const loadTaskChangeSummary = useCallback(
|
||||||
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
|
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -355,71 +379,109 @@ export const TaskDetailDialog = ({
|
||||||
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
|
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const syncTaskChangeSummaryResult = useCallback(
|
||||||
|
(data: TaskChangeSetV2 | null) => {
|
||||||
|
setTaskChangesFiles(data?.files ?? null);
|
||||||
|
if (currentTask && taskChangeRequestOptions) {
|
||||||
|
recordTaskHasChanges(
|
||||||
|
teamName,
|
||||||
|
currentTask.id,
|
||||||
|
taskChangeRequestOptions,
|
||||||
|
!!data?.files.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
||||||
|
if (currentTask && nextPresence) {
|
||||||
|
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentTask,
|
||||||
|
recordTaskHasChanges,
|
||||||
|
setSelectedTeamTaskChangePresence,
|
||||||
|
taskChangeRequestOptions,
|
||||||
|
teamName,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestTaskChangeSummary = useCallback(
|
||||||
|
async ({
|
||||||
|
forceFresh = false,
|
||||||
|
showSpinner = false,
|
||||||
|
preserveFilesOnError = false,
|
||||||
|
}: {
|
||||||
|
forceFresh?: boolean;
|
||||||
|
showSpinner?: boolean;
|
||||||
|
preserveFilesOnError?: boolean;
|
||||||
|
} = {}): Promise<void> => {
|
||||||
|
const requestKey = currentTaskChangeSummaryKeyRef.current;
|
||||||
|
if (taskChangesLoadInFlightRef.current) return;
|
||||||
|
if (
|
||||||
|
!requestKey ||
|
||||||
|
!currentTask ||
|
||||||
|
variant !== 'team' ||
|
||||||
|
!canShowTaskChanges ||
|
||||||
|
!onViewChanges
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
taskChangesLoadInFlightRef.current = true;
|
||||||
|
if (showSpinner) {
|
||||||
|
setTaskChangesLoading(true);
|
||||||
|
}
|
||||||
|
setTaskChangesError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await loadTaskChangeSummary(forceFresh);
|
||||||
|
if (currentTaskChangeSummaryKeyRef.current !== requestKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncTaskChangeSummaryResult(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (currentTaskChangeSummaryKeyRef.current !== requestKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!preserveFilesOnError) {
|
||||||
|
setTaskChangesFiles(null);
|
||||||
|
}
|
||||||
|
setTaskChangesError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
taskChangesLoadInFlightRef.current = false;
|
||||||
|
if (showSpinner) {
|
||||||
|
setTaskChangesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canShowTaskChanges,
|
||||||
|
currentTask,
|
||||||
|
loadTaskChangeSummary,
|
||||||
|
onViewChanges,
|
||||||
|
syncTaskChangeSummaryResult,
|
||||||
|
variant,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant !== 'team') return;
|
if (variant !== 'team') return;
|
||||||
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen)
|
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let cancelled = false;
|
const summaryKey = currentTaskChangeSummaryKey;
|
||||||
|
if (loadedTaskChangeSummaryKeyRef.current === summaryKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadedTaskChangeSummaryKeyRef.current = summaryKey;
|
||||||
|
|
||||||
// Show full loading state only when no files are cached yet;
|
// Show full loading state only when no files are cached yet;
|
||||||
// otherwise let the refresh button spinner indicate background reload.
|
// otherwise let the refresh button spinner indicate background reload.
|
||||||
if (!taskChangesFiles || taskChangesFiles.length === 0) {
|
void requestTaskChangeSummary({
|
||||||
setTaskChangesLoading(true);
|
forceFresh: false,
|
||||||
}
|
showSpinner: !taskChangesFiles || taskChangesFiles.length === 0,
|
||||||
setTaskChangesError(null);
|
preserveFilesOnError: false,
|
||||||
void loadTaskChangeSummary()
|
});
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTaskChangesFiles(data?.files ?? null);
|
|
||||||
if (currentTask && taskChangeRequestOptions) {
|
|
||||||
recordTaskHasChanges(
|
|
||||||
teamName,
|
|
||||||
currentTask.id,
|
|
||||||
taskChangeRequestOptions,
|
|
||||||
!!data?.files.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
|
||||||
if (currentTask && nextPresence) {
|
|
||||||
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTaskChangesFiles(null);
|
|
||||||
setTaskChangesError(
|
|
||||||
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setTaskChangesLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
void loadTaskChangeSummary(true)
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled && data) {
|
|
||||||
setTaskChangesFiles(data.files);
|
|
||||||
if (currentTask && taskChangeRequestOptions) {
|
|
||||||
recordTaskHasChanges(
|
|
||||||
teamName,
|
|
||||||
currentTask.id,
|
|
||||||
taskChangeRequestOptions,
|
|
||||||
data.files.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
|
||||||
if (currentTask && nextPresence) {
|
|
||||||
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
changesSectionOpen,
|
changesSectionOpen,
|
||||||
open,
|
open,
|
||||||
|
|
@ -427,50 +489,54 @@ export const TaskDetailDialog = ({
|
||||||
canShowTaskChanges,
|
canShowTaskChanges,
|
||||||
teamName,
|
teamName,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
taskSince,
|
currentTaskChangeSummaryKey,
|
||||||
|
taskChangeRequestSignature,
|
||||||
variant,
|
variant,
|
||||||
loadTaskChangeSummary,
|
requestTaskChangeSummary,
|
||||||
|
taskChangesFiles,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleRefreshChanges = useCallback(() => {
|
useEffect(() => {
|
||||||
if (!currentTask || variant !== 'team' || !canShowTaskChanges || !onViewChanges) return;
|
if (!open || !changesSectionOpen) {
|
||||||
setTaskChangesLoading(true);
|
loadedTaskChangeSummaryKeyRef.current = null;
|
||||||
setTaskChangesError(null);
|
}
|
||||||
void loadTaskChangeSummary(true)
|
}, [open, changesSectionOpen]);
|
||||||
.then((data) => {
|
|
||||||
setTaskChangesFiles(data?.files ?? null);
|
useEffect(() => {
|
||||||
if (currentTask && taskChangeRequestOptions) {
|
if (variant !== 'team') return;
|
||||||
recordTaskHasChanges(
|
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) {
|
||||||
teamName,
|
return;
|
||||||
currentTask.id,
|
}
|
||||||
taskChangeRequestOptions,
|
|
||||||
!!data?.files.length
|
const timer = window.setInterval(() => {
|
||||||
);
|
void requestTaskChangeSummary({
|
||||||
}
|
forceFresh: true,
|
||||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
showSpinner: false,
|
||||||
if (currentTask && nextPresence) {
|
preserveFilesOnError: true,
|
||||||
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
});
|
||||||
}
|
}, TASK_CHANGES_AUTO_REFRESH_MS);
|
||||||
})
|
|
||||||
.catch((error) => {
|
return () => {
|
||||||
setTaskChangesFiles(null);
|
window.clearInterval(timer);
|
||||||
setTaskChangesError(
|
};
|
||||||
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => setTaskChangesLoading(false));
|
|
||||||
}, [
|
}, [
|
||||||
|
changesSectionOpen,
|
||||||
|
open,
|
||||||
currentTask,
|
currentTask,
|
||||||
canShowTaskChanges,
|
canShowTaskChanges,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
loadTaskChangeSummary,
|
requestTaskChangeSummary,
|
||||||
recordTaskHasChanges,
|
|
||||||
setSelectedTeamTaskChangePresence,
|
|
||||||
taskChangeRequestOptions,
|
|
||||||
teamName,
|
|
||||||
variant,
|
variant,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleRefreshChanges = useCallback(() => {
|
||||||
|
void requestTaskChangeSummary({
|
||||||
|
forceFresh: true,
|
||||||
|
showSpinner: true,
|
||||||
|
preserveFilesOnError: false,
|
||||||
|
});
|
||||||
|
}, [requestTaskChangeSummary]);
|
||||||
|
|
||||||
const handleDependencyClick = (taskId: string): void => {
|
const handleDependencyClick = (taskId: string): void => {
|
||||||
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
|
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
|
||||||
// since kanban cards use the full UUID in data-task-id.
|
// since kanban cards use the full UUID in data-task-id.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
||||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||||
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
||||||
import { ReviewDiffContent } from './ReviewDiffContent';
|
import { ReviewDiffContent } from './ReviewDiffContent';
|
||||||
|
import {
|
||||||
|
shouldRenderCodeMirrorReviewDiff,
|
||||||
|
shouldRenderSnippetReviewPreview,
|
||||||
|
} from './reviewDiffSafety';
|
||||||
|
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { FileChangeWithContent } from '@shared/types';
|
import type { FileChangeWithContent } from '@shared/types';
|
||||||
|
|
@ -47,6 +51,7 @@ export const FileSectionDiff = ({
|
||||||
}: FileSectionDiffProps): React.ReactElement => {
|
}: FileSectionDiffProps): React.ReactElement => {
|
||||||
const localEditorViewRef = useRef<EditorView | null>(null);
|
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canRenderSnippetPreview = shouldRenderSnippetReviewPreview(file.snippets);
|
||||||
|
|
||||||
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
|
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
|
||||||
// This fires on every editor lifecycle event: initial mount, key-change remount,
|
// This fires on every editor lifecycle event: initial mount, key-change remount,
|
||||||
|
|
@ -87,7 +92,11 @@ export const FileSectionDiff = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<ReviewDiffContent file={file} />
|
{canRenderSnippetPreview ? (
|
||||||
|
<ReviewDiffContent file={file} />
|
||||||
|
) : (
|
||||||
|
<OversizedDiffNotice message="Diff preview skipped because the change is too large to render safely." />
|
||||||
|
)}
|
||||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -115,18 +124,29 @@ export const FileSectionDiff = ({
|
||||||
// pretend it's empty — fall back to snippet-level diff.
|
// pretend it's empty — fall back to snippet-level diff.
|
||||||
const canRenderCodeMirror =
|
const canRenderCodeMirror =
|
||||||
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
|
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
|
||||||
|
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
|
||||||
|
const canRenderCodeMirrorSafely =
|
||||||
|
canRenderCodeMirror &&
|
||||||
|
shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? '');
|
||||||
|
|
||||||
if (!canRenderCodeMirror) {
|
if (!canRenderCodeMirrorSafely) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<ReviewDiffContent file={file} />
|
<OversizedDiffNotice
|
||||||
|
message={
|
||||||
|
canRenderCodeMirror && !canRenderSnippetPreview
|
||||||
|
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
|
||||||
|
: canRenderCodeMirror
|
||||||
|
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
|
||||||
|
: 'Diff preview skipped because the available change data is too large to render safely.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{canRenderSnippetPreview ? <ReviewDiffContent file={file} /> : null}
|
||||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
{isMissingOnDisk && (
|
{isMissingOnDisk && (
|
||||||
|
|
@ -170,3 +190,11 @@ export const FileSectionDiff = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function OversizedDiffNotice({ message }: { message: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border bg-amber-500/10 px-4 py-3 text-xs text-amber-300">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
59
src/renderer/components/team/review/reviewDiffSafety.ts
Normal file
59
src/renderer/components/team/review/reviewDiffSafety.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { SnippetDiff } from '@shared/types/review';
|
||||||
|
|
||||||
|
const MAX_CODEMIRROR_DIFF_COMBINED_BYTES = 2 * 1024 * 1024;
|
||||||
|
const MAX_CODEMIRROR_DIFF_LINE_PRODUCT = 1_000_000;
|
||||||
|
const MAX_SNIPPET_PREVIEW_COMBINED_BYTES = 512 * 1024;
|
||||||
|
const MAX_SNIPPET_PREVIEW_TOTAL_LINES = 4_000;
|
||||||
|
|
||||||
|
function countLinesUpTo(text: string, limit: number): number {
|
||||||
|
let lines = 1;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text.charCodeAt(i) === 10) {
|
||||||
|
lines++;
|
||||||
|
if (lines > limit) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRenderCodeMirrorReviewDiff(original: string, modified: string): boolean {
|
||||||
|
const combinedBytes = original.length + modified.length;
|
||||||
|
if (combinedBytes > MAX_CODEMIRROR_DIFF_COMBINED_BYTES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldLines = countLinesUpTo(original, MAX_CODEMIRROR_DIFF_LINE_PRODUCT + 1);
|
||||||
|
const newLines = countLinesUpTo(modified, MAX_CODEMIRROR_DIFF_LINE_PRODUCT + 1);
|
||||||
|
|
||||||
|
return oldLines * newLines <= MAX_CODEMIRROR_DIFF_LINE_PRODUCT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRenderSnippetReviewPreview(snippets: SnippetDiff[]): boolean {
|
||||||
|
let totalBytes = 0;
|
||||||
|
let totalLines = 0;
|
||||||
|
|
||||||
|
for (const snippet of snippets) {
|
||||||
|
if (snippet.isError) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes += snippet.oldString.length + snippet.newString.length;
|
||||||
|
if (totalBytes > MAX_SNIPPET_PREVIEW_COMBINED_BYTES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalLines += countLinesUpTo(snippet.oldString, MAX_SNIPPET_PREVIEW_TOTAL_LINES);
|
||||||
|
if (totalLines > MAX_SNIPPET_PREVIEW_TOTAL_LINES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalLines += countLinesUpTo(snippet.newString, MAX_SNIPPET_PREVIEW_TOTAL_LINES);
|
||||||
|
if (totalLines > MAX_SNIPPET_PREVIEW_TOTAL_LINES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -143,6 +143,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||||
});
|
});
|
||||||
|
// This lightweight renderer-side poll keeps visible in-progress task badges fresh.
|
||||||
|
// It is intentionally independent from the backend log-source tracking feature flag below.
|
||||||
const inProgressChangePresencePollTimer = setInterval(() => {
|
const inProgressChangePresencePollTimer = setInterval(() => {
|
||||||
void pollVisibleTeamInProgressChangePresence();
|
void pollVisibleTeamInProgressChangePresence();
|
||||||
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
|
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
|
||||||
|
|
|
||||||
53
test/renderer/components/reviewDiffSafety.test.ts
Normal file
53
test/renderer/components/reviewDiffSafety.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
shouldRenderCodeMirrorReviewDiff,
|
||||||
|
shouldRenderSnippetReviewPreview,
|
||||||
|
} from '@renderer/components/team/review/reviewDiffSafety';
|
||||||
|
|
||||||
|
describe('reviewDiffSafety', () => {
|
||||||
|
it('allows regular CodeMirror review diffs', () => {
|
||||||
|
expect(shouldRenderCodeMirrorReviewDiff('line 1\nline 2', 'line 1\nline 3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks oversized CodeMirror review diffs by line-product', () => {
|
||||||
|
const original = Array.from({ length: 1200 }, (_, i) => `old ${i}`).join('\n');
|
||||||
|
const modified = Array.from({ length: 1200 }, (_, i) => `new ${i}`).join('\n');
|
||||||
|
|
||||||
|
expect(shouldRenderCodeMirrorReviewDiff(original, modified)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows small snippet previews', () => {
|
||||||
|
expect(
|
||||||
|
shouldRenderSnippetReviewPreview([
|
||||||
|
{
|
||||||
|
filePath: '/tmp/a.ts',
|
||||||
|
oldString: 'const a = 1;\n',
|
||||||
|
newString: 'const a = 2;\n',
|
||||||
|
timestamp: '2026-03-28T10:00:00.000Z',
|
||||||
|
toolUseId: 'tool-1',
|
||||||
|
type: 'edit',
|
||||||
|
replaceAll: false,
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks oversized snippet previews', () => {
|
||||||
|
expect(
|
||||||
|
shouldRenderSnippetReviewPreview([
|
||||||
|
{
|
||||||
|
filePath: '/tmp/big.ts',
|
||||||
|
oldString: '',
|
||||||
|
newString: 'a'.repeat(600 * 1024),
|
||||||
|
timestamp: '2026-03-28T10:00:00.000Z',
|
||||||
|
toolUseId: 'tool-2',
|
||||||
|
type: 'write-update',
|
||||||
|
replaceAll: false,
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue