refactor(ui): Optimize rendering performance (#8239)

This commit is contained in:
Gal Zahavi
2025-09-17 15:37:13 -07:00
committed by GitHub
parent d54cdd8802
commit 6756a8b8a9
13 changed files with 499 additions and 85 deletions

View File

@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
export const FocusContext = createContext<boolean>(true);
export const useFocusState = () => useContext(FocusContext);

View File

@@ -113,6 +113,88 @@ describe('SessionStatsContext', () => {
expect(stats?.lastPromptTokenCount).toBe(100);
});
it('should not update metrics if the data is the same', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
let renderCount = 0;
const CountingTestHarness = () => {
contextRef.current = useSessionStats();
renderCount++;
return null;
};
render(
<SessionStatsProvider>
<CountingTestHarness />
</SessionStatsProvider>,
);
expect(renderCount).toBe(1);
const metrics: SessionMetrics = {
models: {
'gemini-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
act(() => {
uiTelemetryService.emit('update', { metrics, lastPromptTokenCount: 10 });
});
expect(renderCount).toBe(2);
act(() => {
uiTelemetryService.emit('update', { metrics, lastPromptTokenCount: 10 });
});
expect(renderCount).toBe(2);
const newMetrics = {
...metrics,
models: {
'gemini-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
tokens: {
prompt: 20,
candidates: 40,
total: 60,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
};
act(() => {
uiTelemetryService.emit('update', {
metrics: newMetrics,
lastPromptTokenCount: 20,
});
});
expect(renderCount).toBe(3);
});
it('should throw an error when useSessionStats is used outside of a provider', () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

View File

@@ -14,10 +14,129 @@ import {
useEffect,
} from 'react';
import type { SessionMetrics, ModelMetrics } from '@google/gemini-cli-core';
import type {
SessionMetrics,
ModelMetrics,
ToolCallStats,
} from '@google/gemini-cli-core';
import { uiTelemetryService, sessionId } from '@google/gemini-cli-core';
// --- Interface Definitions ---
export enum ToolCallDecision {
ACCEPT = 'accept',
REJECT = 'reject',
MODIFY = 'modify',
AUTO_ACCEPT = 'auto_accept',
}
function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
if (
a.api.totalRequests !== b.api.totalRequests ||
a.api.totalErrors !== b.api.totalErrors ||
a.api.totalLatencyMs !== b.api.totalLatencyMs
) {
return false;
}
if (
a.tokens.prompt !== b.tokens.prompt ||
a.tokens.candidates !== b.tokens.candidates ||
a.tokens.total !== b.tokens.total ||
a.tokens.cached !== b.tokens.cached ||
a.tokens.thoughts !== b.tokens.thoughts ||
a.tokens.tool !== b.tokens.tool
) {
return false;
}
return true;
}
function areToolCallStatsEqual(a: ToolCallStats, b: ToolCallStats): boolean {
if (
a.count !== b.count ||
a.success !== b.success ||
a.fail !== b.fail ||
a.durationMs !== b.durationMs
) {
return false;
}
if (
a.decisions[ToolCallDecision.ACCEPT] !==
b.decisions[ToolCallDecision.ACCEPT] ||
a.decisions[ToolCallDecision.REJECT] !==
b.decisions[ToolCallDecision.REJECT] ||
a.decisions[ToolCallDecision.MODIFY] !==
b.decisions[ToolCallDecision.MODIFY] ||
a.decisions[ToolCallDecision.AUTO_ACCEPT] !==
b.decisions[ToolCallDecision.AUTO_ACCEPT]
) {
return false;
}
return true;
}
function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean {
if (a === b) return true;
if (!a || !b) return false;
// Compare files
if (
a.files.totalLinesAdded !== b.files.totalLinesAdded ||
a.files.totalLinesRemoved !== b.files.totalLinesRemoved
) {
return false;
}
// Compare tools
const toolsA = a.tools;
const toolsB = b.tools;
if (
toolsA.totalCalls !== toolsB.totalCalls ||
toolsA.totalSuccess !== toolsB.totalSuccess ||
toolsA.totalFail !== toolsB.totalFail ||
toolsA.totalDurationMs !== toolsB.totalDurationMs
) {
return false;
}
// Compare tool decisions
if (
toolsA.totalDecisions[ToolCallDecision.ACCEPT] !==
toolsB.totalDecisions[ToolCallDecision.ACCEPT] ||
toolsA.totalDecisions[ToolCallDecision.REJECT] !==
toolsB.totalDecisions[ToolCallDecision.REJECT] ||
toolsA.totalDecisions[ToolCallDecision.MODIFY] !==
toolsB.totalDecisions[ToolCallDecision.MODIFY] ||
toolsA.totalDecisions[ToolCallDecision.AUTO_ACCEPT] !==
toolsB.totalDecisions[ToolCallDecision.AUTO_ACCEPT]
) {
return false;
}
// Compare tools.byName
const toolsByNameAKeys = Object.keys(toolsA.byName);
const toolsByNameBKeys = Object.keys(toolsB.byName);
if (toolsByNameAKeys.length !== toolsByNameBKeys.length) return false;
for (const key of toolsByNameAKeys) {
const toolA = toolsA.byName[key];
const toolB = toolsB.byName[key];
if (!toolB || !areToolCallStatsEqual(toolA, toolB)) {
return false;
}
}
// Compare models
const modelsAKeys = Object.keys(a.models);
const modelsBKeys = Object.keys(b.models);
if (modelsAKeys.length !== modelsBKeys.length) return false;
for (const key of modelsAKeys) {
if (!b.models[key] || !areModelMetricsEqual(a.models[key], b.models[key])) {
return false;
}
}
return true;
}
export type { SessionMetrics, ModelMetrics };
@@ -80,11 +199,19 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
metrics: SessionMetrics;
lastPromptTokenCount: number;
}) => {
setStats((prevState) => ({
...prevState,
metrics,
lastPromptTokenCount,
}));
setStats((prevState) => {
if (
prevState.lastPromptTokenCount === lastPromptTokenCount &&
areMetricsEqual(prevState.metrics, metrics)
) {
return prevState;
}
return {
...prevState,
metrics,
lastPromptTokenCount,
};
});
};
uiTelemetryService.on('update', handleUpdate);

View File

@@ -78,7 +78,6 @@ export interface UIState {
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
isFocused: boolean;
elapsedTime: number;
currentLoadingPhrase: string;
historyRemountKey: number;