fix(graph): toggle buttons visually distinct — active gets border + glow, inactive dimmed

This commit is contained in:
iliya 2026-03-28 14:11:28 +02:00
parent b7064759c0
commit 8a9121fc3e
6 changed files with 309 additions and 101 deletions

View file

@ -178,10 +178,10 @@ function ToolbarToggle({
return (
<button
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
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.12)]'
: 'text-[#66ccff50] hover:text-[#66ccff90]'
? '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-[#66ccff40] bg-transparent border-transparent hover:text-[#66ccff70] hover:border-[rgba(100,200,255,0.1)]'
}`}
>
{icon}

View file

@ -44,7 +44,11 @@ import {
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} 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 { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
@ -78,6 +82,8 @@ import {
X,
} from 'lucide-react';
const TASK_CHANGES_AUTO_REFRESH_MS = 20_000;
import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments';
import { WorkflowTimeline } from './StatusHistoryTimeline';
@ -154,6 +160,9 @@ export const TaskDetailDialog = ({
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
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
const [editingSubject, setEditingSubject] = useState(false);
@ -323,6 +332,17 @@ export const TaskDetailDialog = ({
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
[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(
() =>
currentTask
@ -335,6 +355,10 @@ export const TaskDetailDialog = ({
);
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
useEffect(() => {
currentTaskChangeSummaryKeyRef.current = currentTaskChangeSummaryKey;
}, [currentTaskChangeSummaryKey]);
const loadTaskChangeSummary = useCallback(
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
if (
@ -355,71 +379,109 @@ export const TaskDetailDialog = ({
[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(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen)
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;
// otherwise let the refresh button spinner indicate background reload.
if (!taskChangesFiles || taskChangesFiles.length === 0) {
setTaskChangesLoading(true);
}
setTaskChangesError(null);
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;
};
void requestTaskChangeSummary({
forceFresh: false,
showSpinner: !taskChangesFiles || taskChangesFiles.length === 0,
preserveFilesOnError: false,
});
}, [
changesSectionOpen,
open,
@ -427,50 +489,54 @@ export const TaskDetailDialog = ({
canShowTaskChanges,
teamName,
onViewChanges,
taskSince,
currentTaskChangeSummaryKey,
taskChangeRequestSignature,
variant,
loadTaskChangeSummary,
requestTaskChangeSummary,
taskChangesFiles,
]);
const handleRefreshChanges = useCallback(() => {
if (!currentTask || variant !== 'team' || !canShowTaskChanges || !onViewChanges) return;
setTaskChangesLoading(true);
setTaskChangesError(null);
void loadTaskChangeSummary(true)
.then((data) => {
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) => {
setTaskChangesFiles(null);
setTaskChangesError(
error instanceof Error ? error.message : 'Failed to load task changes summary'
);
})
.finally(() => setTaskChangesLoading(false));
useEffect(() => {
if (!open || !changesSectionOpen) {
loadedTaskChangeSummaryKeyRef.current = null;
}
}, [open, changesSectionOpen]);
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) {
return;
}
const timer = window.setInterval(() => {
void requestTaskChangeSummary({
forceFresh: true,
showSpinner: false,
preserveFilesOnError: true,
});
}, TASK_CHANGES_AUTO_REFRESH_MS);
return () => {
window.clearInterval(timer);
};
}, [
changesSectionOpen,
open,
currentTask,
canShowTaskChanges,
onViewChanges,
loadTaskChangeSummary,
recordTaskHasChanges,
setSelectedTeamTaskChangePresence,
taskChangeRequestOptions,
teamName,
requestTaskChangeSummary,
variant,
]);
const handleRefreshChanges = useCallback(() => {
void requestTaskChangeSummary({
forceFresh: true,
showSpinner: true,
preserveFilesOnError: false,
});
}, [requestTaskChangeSummary]);
const handleDependencyClick = (taskId: string): void => {
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
// since kanban cards use the full UUID in data-task-id.

View file

@ -4,6 +4,10 @@ import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { DiffErrorBoundary } from './DiffErrorBoundary';
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
import { ReviewDiffContent } from './ReviewDiffContent';
import {
shouldRenderCodeMirrorReviewDiff,
shouldRenderSnippetReviewPreview,
} from './reviewDiffSafety';
import type { EditorView } from '@codemirror/view';
import type { FileChangeWithContent } from '@shared/types';
@ -47,6 +51,7 @@ export const FileSectionDiff = ({
}: FileSectionDiffProps): React.ReactElement => {
const localEditorViewRef = useRef<EditorView | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const canRenderSnippetPreview = shouldRenderSnippetReviewPreview(file.snippets);
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
// This fires on every editor lifecycle event: initial mount, key-change remount,
@ -87,7 +92,11 @@ export const FileSectionDiff = ({
return (
<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>
);
@ -115,18 +124,29 @@ export const FileSectionDiff = ({
// pretend it's empty — fall back to snippet-level diff.
const canRenderCodeMirror =
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
const canRenderCodeMirrorSafely =
canRenderCodeMirror &&
shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? '');
if (!canRenderCodeMirror) {
if (!canRenderCodeMirrorSafely) {
return (
<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>
);
}
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
return (
<div className="overflow-auto">
{isMissingOnDisk && (
@ -170,3 +190,11 @@ export const FileSectionDiff = ({
</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>
);
}

View 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;
}

View file

@ -143,6 +143,8 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => {
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(() => {
void pollVisibleTeamInProgressChangePresence();
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);

View 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);
});
});