feat(ui): add visual indicators for hook execution (#15408)

This commit is contained in:
Abhi
2026-01-06 15:52:12 -05:00
committed by GitHub
parent 86b5995f12
commit 61dbab03e0
27 changed files with 1124 additions and 73 deletions

View File

@@ -0,0 +1,234 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { useHookDisplayState } from './useHookDisplayState.js';
import {
coreEvents,
CoreEvent,
type HookStartPayload,
type HookEndPayload,
} from '@google/gemini-cli-core';
import { act } from 'react';
describe('useHookDisplayState', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
coreEvents.removeAllListeners(CoreEvent.HookStart);
coreEvents.removeAllListeners(CoreEvent.HookEnd);
});
it('should initialize with empty hooks', () => {
const { result } = renderHook(() => useHookDisplayState());
expect(result.current).toEqual([]);
});
it('should add a hook when HookStart event is emitted', () => {
const { result } = renderHook(() => useHookDisplayState());
const payload: HookStartPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
hookIndex: 1,
totalHooks: 1,
};
act(() => {
coreEvents.emitHookStart(payload);
});
expect(result.current).toHaveLength(1);
expect(result.current[0]).toMatchObject({
name: 'test-hook',
eventName: 'before-agent',
});
});
it('should remove a hook immediately if duration > 1s', () => {
const { result } = renderHook(() => useHookDisplayState());
const startPayload: HookStartPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
};
act(() => {
coreEvents.emitHookStart(startPayload);
});
// Advance time by 1.1 seconds
act(() => {
vi.advanceTimersByTime(1100);
});
const endPayload: HookEndPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
success: true,
};
act(() => {
coreEvents.emitHookEnd(endPayload);
});
expect(result.current).toHaveLength(0);
});
it('should delay removal if duration < 1s', () => {
const { result } = renderHook(() => useHookDisplayState());
const startPayload: HookStartPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
};
act(() => {
coreEvents.emitHookStart(startPayload);
});
// Advance time by only 100ms
act(() => {
vi.advanceTimersByTime(100);
});
const endPayload: HookEndPayload = {
hookName: 'test-hook',
eventName: 'before-agent',
success: true,
};
act(() => {
coreEvents.emitHookEnd(endPayload);
});
// Should still be present
expect(result.current).toHaveLength(1);
// Advance remaining time (900ms needed, let's go 950ms)
act(() => {
vi.advanceTimersByTime(950);
});
expect(result.current).toHaveLength(0);
});
it('should handle multiple hooks correctly', () => {
const { result } = renderHook(() => useHookDisplayState());
act(() => {
coreEvents.emitHookStart({ hookName: 'h1', eventName: 'e1' });
});
act(() => {
vi.advanceTimersByTime(500);
});
act(() => {
coreEvents.emitHookStart({ hookName: 'h2', eventName: 'e1' });
});
expect(result.current).toHaveLength(2);
// End h1 (total time 500ms -> needs 500ms delay)
act(() => {
coreEvents.emitHookEnd({
hookName: 'h1',
eventName: 'e1',
success: true,
});
});
// h1 still there
expect(result.current).toHaveLength(2);
// Advance 600ms. h1 should disappear. h2 has been running for 600ms.
act(() => {
vi.advanceTimersByTime(600);
});
expect(result.current).toHaveLength(1);
expect(result.current[0].name).toBe('h2');
// End h2 (total time 600ms -> needs 400ms delay)
act(() => {
coreEvents.emitHookEnd({
hookName: 'h2',
eventName: 'e1',
success: true,
});
});
expect(result.current).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toHaveLength(0);
});
it('should handle interleaved hooks with same name and event', () => {
const { result } = renderHook(() => useHookDisplayState());
const hook = { hookName: 'same-hook', eventName: 'same-event' };
// Start Hook 1 at t=0
act(() => {
coreEvents.emitHookStart(hook);
});
// Advance to t=500
act(() => {
vi.advanceTimersByTime(500);
});
// Start Hook 2 at t=500
act(() => {
coreEvents.emitHookStart(hook);
});
expect(result.current).toHaveLength(2);
expect(result.current[0].name).toBe('same-hook');
expect(result.current[1].name).toBe('same-hook');
// End Hook 1 at t=600 (Duration 600ms -> delay 400ms)
act(() => {
vi.advanceTimersByTime(100);
coreEvents.emitHookEnd({ ...hook, success: true });
});
// Both still visible (Hook 1 pending removal in 400ms)
expect(result.current).toHaveLength(2);
// Advance 400ms (t=1000). Hook 1 should be removed.
act(() => {
vi.advanceTimersByTime(400);
});
expect(result.current).toHaveLength(1);
// End Hook 2 at t=1100 (Duration: 1100 - 500 = 600ms -> delay 400ms)
act(() => {
vi.advanceTimersByTime(100);
coreEvents.emitHookEnd({ ...hook, success: true });
});
// Hook 2 still visible (pending removal in 400ms)
expect(result.current).toHaveLength(1);
// Advance 400ms (t=1500). Hook 2 should be removed.
act(() => {
vi.advanceTimersByTime(400);
});
expect(result.current).toHaveLength(0);
});
});

View File

@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import {
coreEvents,
CoreEvent,
type HookStartPayload,
type HookEndPayload,
} from '@google/gemini-cli-core';
import { type ActiveHook } from '../types.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
export const useHookDisplayState = () => {
const [activeHooks, setActiveHooks] = useState<ActiveHook[]>([]);
// Track start times independently of render state to calculate duration in event handlers
// Key: `${hookName}:${eventName}` -> Stack of StartTimes (FIFO)
const hookStartTimes = useRef<Map<string, number[]>>(new Map());
// Track active timeouts to clear them on unmount
const timeouts = useRef<Set<NodeJS.Timeout>>(new Set());
useEffect(() => {
const activeTimeouts = timeouts.current;
const startTimes = hookStartTimes.current;
const handleHookStart = (payload: HookStartPayload) => {
const key = `${payload.hookName}:${payload.eventName}`;
const now = Date.now();
// Add start time to ref
if (!startTimes.has(key)) {
startTimes.set(key, []);
}
startTimes.get(key)!.push(now);
setActiveHooks((prev) => [
...prev,
{
name: payload.hookName,
eventName: payload.eventName,
index: payload.hookIndex,
total: payload.totalHooks,
},
]);
};
const handleHookEnd = (payload: HookEndPayload) => {
const key = `${payload.hookName}:${payload.eventName}`;
const starts = startTimes.get(key);
const startTime = starts?.shift(); // Get the earliest start time for this hook type
// Cleanup empty arrays in map
if (starts && starts.length === 0) {
startTimes.delete(key);
}
const now = Date.now();
// Default to immediate removal if start time not found (defensive)
const elapsed = startTime ? now - startTime : WARNING_PROMPT_DURATION_MS;
const remaining = WARNING_PROMPT_DURATION_MS - elapsed;
const removeHook = () => {
setActiveHooks((prev) => {
const index = prev.findIndex(
(h) =>
h.name === payload.hookName && h.eventName === payload.eventName,
);
if (index === -1) return prev;
const newHooks = [...prev];
newHooks.splice(index, 1);
return newHooks;
});
};
if (remaining > 0) {
const timeoutId = setTimeout(() => {
removeHook();
activeTimeouts.delete(timeoutId);
}, remaining);
activeTimeouts.add(timeoutId);
} else {
removeHook();
}
};
coreEvents.on(CoreEvent.HookStart, handleHookStart);
coreEvents.on(CoreEvent.HookEnd, handleHookEnd);
return () => {
coreEvents.off(CoreEvent.HookStart, handleHookStart);
coreEvents.off(CoreEvent.HookEnd, handleHookEnd);
// Clear all pending timeouts
activeTimeouts.forEach(clearTimeout);
activeTimeouts.clear();
};
}, []);
return activeHooks;
};